diff --git a/package.json b/package.json index 9e6eb5c2..61d4ca25 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "private": true, "packageManager": "pnpm@10.6.2", "scripts": { - "build": "turbo run build --filter=gridplus-sdk --filter=@gridplus/chain-core --filter=@gridplus/btc --filter=@gridplus/cosmos --filter=@gridplus/evm --filter=@gridplus/solana --filter=@gridplus/types", + "build": "turbo run build --filter=gridplus-sdk --filter=@gridplus/chain-core --filter=@gridplus/btc --filter=@gridplus/cosmos --filter=@gridplus/evm --filter=@gridplus/solana --filter=@gridplus/types --filter=@gridplus/xrp", "test": "turbo run test --filter=gridplus-sdk --filter=@gridplus/btc", "test-unit": "turbo run test-unit --filter=gridplus-sdk --filter=@gridplus/btc", - "lint": "turbo run lint --filter=gridplus-sdk --filter=@gridplus/chain-core --filter=@gridplus/btc --filter=@gridplus/cosmos --filter=@gridplus/evm --filter=@gridplus/solana --filter=@gridplus/types", - "lint:fix": "turbo run lint:fix --filter=gridplus-sdk --filter=@gridplus/chain-core --filter=@gridplus/btc --filter=@gridplus/cosmos --filter=@gridplus/evm --filter=@gridplus/solana --filter=@gridplus/types", - "typecheck": "turbo run typecheck --filter=gridplus-sdk --filter=@gridplus/chain-core --filter=@gridplus/btc --filter=@gridplus/cosmos --filter=@gridplus/evm --filter=@gridplus/solana --filter=@gridplus/types", + "lint": "turbo run lint --filter=gridplus-sdk --filter=@gridplus/chain-core --filter=@gridplus/btc --filter=@gridplus/cosmos --filter=@gridplus/evm --filter=@gridplus/solana --filter=@gridplus/types --filter=@gridplus/xrp", + "lint:fix": "turbo run lint:fix --filter=gridplus-sdk --filter=@gridplus/chain-core --filter=@gridplus/btc --filter=@gridplus/cosmos --filter=@gridplus/evm --filter=@gridplus/solana --filter=@gridplus/types --filter=@gridplus/xrp", + "typecheck": "turbo run typecheck --filter=gridplus-sdk --filter=@gridplus/chain-core --filter=@gridplus/btc --filter=@gridplus/cosmos --filter=@gridplus/evm --filter=@gridplus/solana --filter=@gridplus/types --filter=@gridplus/xrp", "e2e": "turbo run e2e --filter=gridplus-sdk", "docs:build": "pnpm --filter gridplus-sdk-docs run build", "docs:start": "pnpm --filter gridplus-sdk-docs run start" diff --git a/packages/chains/btc/src/chain.ts b/packages/chains/btc/src/chain.ts index 1ae698cd..2d4ea5f1 100644 --- a/packages/chains/btc/src/chain.ts +++ b/packages/chains/btc/src/chain.ts @@ -138,6 +138,7 @@ const resolvePath = ( params?: BtcGetAddressParams, options?: BtcAdapterOptions, ): DerivationPath => { + if (params?.path) return params.path; return buildPath( resolvePurpose(params, options), resolveCoinType(params, options), diff --git a/packages/chains/btc/src/devices/shared.ts b/packages/chains/btc/src/devices/shared.ts index a90f04dc..92037e44 100644 --- a/packages/chains/btc/src/devices/shared.ts +++ b/packages/chains/btc/src/devices/shared.ts @@ -1,18 +1,4 @@ -export function compressSecp256k1Pubkey(pubkey: Uint8Array): Uint8Array { - if (pubkey.length === 33 && (pubkey[0] === 0x02 || pubkey[0] === 0x03)) { - return pubkey; - } - if (pubkey.length === 65 && pubkey[0] === 0x04) { - const x = pubkey.slice(1, 33); - const yLastByte = pubkey[64]; - const prefix = yLastByte % 2 === 0 ? 0x02 : 0x03; - const out = new Uint8Array(33); - out[0] = prefix; - out.set(x, 1); - return out; - } - return pubkey; -} +export { compressSecp256k1Pubkey } from '@gridplus/chain-core'; export function normalizeBtcSignedTxHex(tx?: string): string | undefined { if (!tx) return undefined; diff --git a/packages/chains/chain-core/src/index.ts b/packages/chains/chain-core/src/index.ts index 25f7e407..c31caf00 100644 --- a/packages/chains/chain-core/src/index.ts +++ b/packages/chains/chain-core/src/index.ts @@ -247,3 +247,87 @@ export function createChainRegistry( resolve, }; } + +// --------------------------------------------------------------------------- +// Shared chain utilities +// --------------------------------------------------------------------------- + +export function compressSecp256k1Pubkey(pubkey: Uint8Array): Uint8Array { + if (pubkey.length === 33 && (pubkey[0] === 0x02 || pubkey[0] === 0x03)) { + return pubkey; + } + if (pubkey.length === 65 && pubkey[0] === 0x04) { + const x = pubkey.slice(1, 33); + const yLastByte = pubkey[64]; + const prefix = yLastByte % 2 === 0 ? 0x02 : 0x03; + const out = new Uint8Array(33); + out[0] = prefix; + out.set(x, 1); + return out; + } + return pubkey; +} + +export function toBuffer(value: unknown): Buffer { + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array) return Buffer.from(value); + if (typeof value === 'string') { + const hex = value.startsWith('0x') ? value.slice(2) : value; + return Buffer.from(hex, 'hex'); + } + throw new Error('Unsupported byte input'); +} + +export function parseHexBytes( + value: unknown, + expectedLen?: number, +): Uint8Array { + if (typeof value === 'string') { + const hex = value.startsWith('0x') ? value.slice(2) : value; + const buf = Buffer.from(hex, 'hex'); + if ( + expectedLen !== undefined && + buf.length !== expectedLen && + buf.length < expectedLen + ) { + const out = Buffer.alloc(expectedLen); + buf.copy(out, expectedLen - buf.length); + return new Uint8Array(out); + } + return new Uint8Array(buf); + } + if (Buffer.isBuffer(value)) return new Uint8Array(value); + if (value instanceof Uint8Array) return value; + throw new Error('Unsupported signature component type'); +} + +export function buildSigResultFromRsv(sig: { + r?: unknown; + s?: unknown; + v?: unknown; +}): { + signature: { + bytes: Uint8Array; + r?: Uint8Array; + s?: Uint8Array; + v?: bigint | number; + }; +} { + const r = sig.r !== undefined ? parseHexBytes(sig.r, 32) : undefined; + const s = sig.s !== undefined ? parseHexBytes(sig.s, 32) : undefined; + + let v: bigint | number | undefined; + if (typeof sig.v === 'bigint') v = sig.v; + else if (typeof sig.v === 'number') v = sig.v; + else if (typeof sig.v === 'string') v = BigInt(sig.v); + else if (Buffer.isBuffer(sig.v) || sig.v instanceof Uint8Array) { + const buf = Buffer.from(sig.v); + v = buf.length === 0 ? 0n : BigInt(`0x${buf.toString('hex')}`); + } + + const bytes = + r && s + ? new Uint8Array(Buffer.concat([Buffer.from(r), Buffer.from(s)])) + : new Uint8Array(); + return { signature: { bytes, r, s, v } }; +} diff --git a/packages/chains/chain-registry-architecture.md b/packages/chains/chain-registry-architecture.md index 303d8332..54d89da2 100644 --- a/packages/chains/chain-registry-architecture.md +++ b/packages/chains/chain-registry-architecture.md @@ -23,11 +23,12 @@ flowchart LR CoreRegistry["createChainRegistry()\nChainPlugin + DeviceId + resolve()"] end - subgraph Chains["@gridplus/{btc,evm,solana,cosmos}"] + subgraph Chains["@gridplus/{btc,evm,solana,cosmos,xrp}"] Btc["btc/src/devices/lattice.ts\nlatticePlugin"] Evm["evm/src/devices/lattice.ts\nlatticePlugin"] Sol["solana/src/devices/lattice.ts\nlatticePlugin"] Cos["cosmos/src/devices/lattice.ts\nlatticePlugin"] + Xrp["xrp/src/devices/lattice.ts\nlatticePlugin"] Cadix["future: */src/devices/cadix.ts\ncadixPlugin"] end @@ -36,6 +37,7 @@ flowchart LR Manifest --> Evm Manifest --> Sol Manifest --> Cos + Manifest --> Xrp CustomReg --> Cadix Registry --> Plugin["resolved plugin (chainId:device)"] @@ -137,3 +139,4 @@ stateDiagram-v2 - `packages/chains/evm/src/devices/lattice.ts` - `packages/chains/solana/src/devices/lattice.ts` - `packages/chains/cosmos/src/devices/lattice.ts` +- `packages/chains/xrp/src/devices/lattice.ts` diff --git a/packages/chains/cosmos/src/chain.ts b/packages/chains/cosmos/src/chain.ts index 87b82850..b9bca745 100644 --- a/packages/chains/cosmos/src/chain.ts +++ b/packages/chains/cosmos/src/chain.ts @@ -11,6 +11,7 @@ import type { SignResult, Signer as CoreSigner, } from '@gridplus/chain-core'; +import { compressSecp256k1Pubkey } from '@gridplus/chain-core'; import { bech32 } from 'bech32'; import { ripemd160 } from '@noble/hashes/ripemd160'; import { sha256 } from '@noble/hashes/sha256'; @@ -81,23 +82,6 @@ const buildPath = ( ]; }; -function compressSecp256k1Pubkey(pubkey: Uint8Array): Uint8Array { - if (pubkey.length === 33 && (pubkey[0] === 0x02 || pubkey[0] === 0x03)) { - return pubkey; - } - if (pubkey.length === 65 && pubkey[0] === 0x04) { - const x = pubkey.slice(1, 33); - const yLastByte = pubkey[64]; - const prefix = yLastByte % 2 === 0 ? 0x02 : 0x03; - const out = new Uint8Array(33); - out[0] = prefix; - out.set(x, 1); - return out; - } - // Unknown format, return as-is. - return pubkey; -} - const pubkeyToBech32Address = (pubkeyCompressed: Uint8Array, hrp: string) => { const digest = ripemd160(sha256(pubkeyCompressed)); const words = bech32.toWords(digest); diff --git a/packages/chains/cosmos/src/devices/shared.ts b/packages/chains/cosmos/src/devices/shared.ts index 6f509496..98a801e8 100644 --- a/packages/chains/cosmos/src/devices/shared.ts +++ b/packages/chains/cosmos/src/devices/shared.ts @@ -1,69 +1,5 @@ -export function compressSecp256k1Pubkey(pubkey: Uint8Array): Uint8Array { - if (pubkey.length === 33 && (pubkey[0] === 0x02 || pubkey[0] === 0x03)) { - return pubkey; - } - if (pubkey.length === 65 && pubkey[0] === 0x04) { - const x = pubkey.slice(1, 33); - const yLastByte = pubkey[64]; - const prefix = yLastByte % 2 === 0 ? 0x02 : 0x03; - const out = new Uint8Array(33); - out[0] = prefix; - out.set(x, 1); - return out; - } - return pubkey; -} - -export function parseHexBytes( - value: unknown, - expectedLen?: number, -): Uint8Array { - if (typeof value === 'string') { - const hex = value.startsWith('0x') ? value.slice(2) : value; - const buf = Buffer.from(hex, 'hex'); - if ( - expectedLen !== undefined && - buf.length !== expectedLen && - buf.length < expectedLen - ) { - const out = Buffer.alloc(expectedLen); - buf.copy(out, expectedLen - buf.length); - return new Uint8Array(out); - } - return new Uint8Array(buf); - } - if (Buffer.isBuffer(value)) return new Uint8Array(value); - if (value instanceof Uint8Array) return value; - throw new Error('Unsupported signature component type'); -} - -export function buildSigResultFromRsv(sig: { - r?: unknown; - s?: unknown; - v?: unknown; -}): { - signature: { - bytes: Uint8Array; - r?: Uint8Array; - s?: Uint8Array; - v?: bigint | number; - }; -} { - const r = sig.r !== undefined ? parseHexBytes(sig.r, 32) : undefined; - const s = sig.s !== undefined ? parseHexBytes(sig.s, 32) : undefined; - - let v: bigint | number | undefined; - if (typeof sig.v === 'bigint') v = sig.v; - else if (typeof sig.v === 'number') v = sig.v; - else if (typeof sig.v === 'string') v = BigInt(sig.v); - else if (Buffer.isBuffer(sig.v) || sig.v instanceof Uint8Array) { - const buf = Buffer.from(sig.v); - v = buf.length === 0 ? 0n : BigInt(`0x${buf.toString('hex')}`); - } - - const bytes = - r && s - ? new Uint8Array(Buffer.concat([Buffer.from(r), Buffer.from(s)])) - : new Uint8Array(); - return { signature: { bytes, r, s, v } }; -} +export { + compressSecp256k1Pubkey, + parseHexBytes, + buildSigResultFromRsv, +} from '@gridplus/chain-core'; diff --git a/packages/chains/evm/src/devices/shared.ts b/packages/chains/evm/src/devices/shared.ts index 56f1568e..5504e86a 100644 --- a/packages/chains/evm/src/devices/shared.ts +++ b/packages/chains/evm/src/devices/shared.ts @@ -4,82 +4,9 @@ export function isHexString(value: unknown): value is Hex { return typeof value === 'string' && value.startsWith('0x'); } -export function toBuffer(value: unknown): Buffer { - if (Buffer.isBuffer(value)) return value; - if (value instanceof Uint8Array) return Buffer.from(value); - if (typeof value === 'string') { - const hex = value.startsWith('0x') ? value.slice(2) : value; - return Buffer.from(hex, 'hex'); - } - throw new Error('Unsupported byte input'); -} - -export function compressSecp256k1Pubkey(pubkey: Uint8Array): Uint8Array { - if (pubkey.length === 33 && (pubkey[0] === 0x02 || pubkey[0] === 0x03)) { - return pubkey; - } - if (pubkey.length === 65 && pubkey[0] === 0x04) { - const x = pubkey.slice(1, 33); - const yLastByte = pubkey[64]; - const prefix = yLastByte % 2 === 0 ? 0x02 : 0x03; - const out = new Uint8Array(33); - out[0] = prefix; - out.set(x, 1); - return out; - } - return pubkey; -} - -export function parseHexBytes( - value: unknown, - expectedLen?: number, -): Uint8Array { - if (typeof value === 'string') { - const hex = value.startsWith('0x') ? value.slice(2) : value; - const buf = Buffer.from(hex, 'hex'); - if ( - expectedLen !== undefined && - buf.length !== expectedLen && - buf.length < expectedLen - ) { - const out = Buffer.alloc(expectedLen); - buf.copy(out, expectedLen - buf.length); - return new Uint8Array(out); - } - return new Uint8Array(buf); - } - if (Buffer.isBuffer(value)) return new Uint8Array(value); - if (value instanceof Uint8Array) return value; - throw new Error('Unsupported signature component type'); -} - -export function buildSigResultFromRsv(sig: { - r?: unknown; - s?: unknown; - v?: unknown; -}): { - signature: { - bytes: Uint8Array; - r?: Uint8Array; - s?: Uint8Array; - v?: bigint | number; - }; -} { - const r = sig.r !== undefined ? parseHexBytes(sig.r, 32) : undefined; - const s = sig.s !== undefined ? parseHexBytes(sig.s, 32) : undefined; - - let v: bigint | number | undefined; - if (typeof sig.v === 'bigint') v = sig.v; - else if (typeof sig.v === 'number') v = sig.v; - else if (typeof sig.v === 'string') v = BigInt(sig.v); - else if (Buffer.isBuffer(sig.v) || sig.v instanceof Uint8Array) { - const buf = Buffer.from(sig.v); - v = buf.length === 0 ? 0n : BigInt(`0x${buf.toString('hex')}`); - } - - const bytes = - r && s - ? new Uint8Array(Buffer.concat([Buffer.from(r), Buffer.from(s)])) - : new Uint8Array(); - return { signature: { bytes, r, s, v } }; -} +export { + compressSecp256k1Pubkey, + toBuffer, + parseHexBytes, + buildSigResultFromRsv, +} from '@gridplus/chain-core'; diff --git a/packages/chains/solana/src/devices/shared.ts b/packages/chains/solana/src/devices/shared.ts index 7f91745f..d742dfc9 100644 --- a/packages/chains/solana/src/devices/shared.ts +++ b/packages/chains/solana/src/devices/shared.ts @@ -1,63 +1,5 @@ -export function toBuffer(value: unknown): Buffer { - if (Buffer.isBuffer(value)) return value; - if (value instanceof Uint8Array) return Buffer.from(value); - if (typeof value === 'string') { - const hex = value.startsWith('0x') ? value.slice(2) : value; - return Buffer.from(hex, 'hex'); - } - throw new Error('Unsupported byte input'); -} - -export function parseHexBytes( - value: unknown, - expectedLen?: number, -): Uint8Array { - if (typeof value === 'string') { - const hex = value.startsWith('0x') ? value.slice(2) : value; - const buf = Buffer.from(hex, 'hex'); - if ( - expectedLen !== undefined && - buf.length !== expectedLen && - buf.length < expectedLen - ) { - const out = Buffer.alloc(expectedLen); - buf.copy(out, expectedLen - buf.length); - return new Uint8Array(out); - } - return new Uint8Array(buf); - } - if (Buffer.isBuffer(value)) return new Uint8Array(value); - if (value instanceof Uint8Array) return value; - throw new Error('Unsupported signature component type'); -} - -export function buildSigResultFromRsv(sig: { - r?: unknown; - s?: unknown; - v?: unknown; -}): { - signature: { - bytes: Uint8Array; - r?: Uint8Array; - s?: Uint8Array; - v?: bigint | number; - }; -} { - const r = sig.r !== undefined ? parseHexBytes(sig.r, 32) : undefined; - const s = sig.s !== undefined ? parseHexBytes(sig.s, 32) : undefined; - - let v: bigint | number | undefined; - if (typeof sig.v === 'bigint') v = sig.v; - else if (typeof sig.v === 'number') v = sig.v; - else if (typeof sig.v === 'string') v = BigInt(sig.v); - else if (Buffer.isBuffer(sig.v) || sig.v instanceof Uint8Array) { - const buf = Buffer.from(sig.v); - v = buf.length === 0 ? 0n : BigInt(`0x${buf.toString('hex')}`); - } - - const bytes = - r && s - ? new Uint8Array(Buffer.concat([Buffer.from(r), Buffer.from(s)])) - : new Uint8Array(); - return { signature: { bytes, r, s, v } }; -} +export { + toBuffer, + parseHexBytes, + buildSigResultFromRsv, +} from '@gridplus/chain-core'; diff --git a/packages/chains/xrp/biome.json b/packages/chains/xrp/biome.json new file mode 100644 index 00000000..db53357d --- /dev/null +++ b/packages/chains/xrp/biome.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "extends": ["../../../biome.json"], + "files": { + "include": ["src/**/*.ts"], + "ignore": ["**/dist/**", "**/node_modules/**"] + } +} diff --git a/packages/chains/xrp/package.json b/packages/chains/xrp/package.json new file mode 100644 index 00000000..efbf5e65 --- /dev/null +++ b/packages/chains/xrp/package.json @@ -0,0 +1,53 @@ +{ + "name": "@gridplus/xrp", + "version": "0.1.0", + "type": "module", + "description": "XRP chain interface for GridPlus SDK", + "scripts": { + "build": "tsup", + "lint": "biome check src", + "lint:fix": "biome check --write src", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/GridPlus/gridplus-sdk.git", + "directory": "packages/chains/xrp" + }, + "gridplus": { + "chainPlugin": { + "chainId": "xrp", + "device": "lattice", + "export": "latticePlugin" + } + }, + "dependencies": { + "@gridplus/chain-core": "workspace:*", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.0", + "@types/node": "^24.10.4", + "tsup": "^8.5.0", + "typescript": "^5.9.2" + }, + "license": "MIT", + "engines": { + "node": ">=20" + } +} diff --git a/packages/chains/xrp/src/chain.ts b/packages/chains/xrp/src/chain.ts new file mode 100644 index 00000000..80adb1bc --- /dev/null +++ b/packages/chains/xrp/src/chain.ts @@ -0,0 +1,278 @@ +import type { + Account, + Address, + ChainAdapter, + ChainModule, + DerivationPath, + GetAccountsParams, + GetAddressParams, + GetPublicKeyParams, + PublicKey, + SignResult, + Signer as CoreSigner, +} from '@gridplus/chain-core'; +import { compressSecp256k1Pubkey } from '@gridplus/chain-core'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +import { sha256 } from '@noble/hashes/sha256'; +import { base58xrp } from '@scure/base'; + +const HARDENED_OFFSET = 0x80000000; +const XRP_COIN_TYPE = 144; +const XRP_ADDRESS_VERSION = 0x00; +const XRP_ACCOUNT_ID_LEN = 20; +const XRP_CHECKSUM_LEN = 4; +const XRP_ADDRESS_BYTES = 1 + XRP_ACCOUNT_ID_LEN + XRP_CHECKSUM_LEN; + +export type XrpSignRequest = { + kind: 'transaction'; + /** XRPL signing preimage bytes (typically STX\\0 + canonical serialized transaction). */ + payload: Uint8Array | Buffer; + options?: { path?: DerivationPath }; +}; + +export type Signer = CoreSigner; + +export type XrpGetAddressParams = GetAddressParams & { + includePublicKey?: boolean; +}; + +export type XrpGetPublicKeyParams = GetPublicKeyParams; + +export type XrpAdapterOptions = { + accountIndex?: number; + change?: number; + addressIndex?: number; +}; + +export type XrpAdapter = ChainAdapter< + XrpSignRequest, + XrpGetAddressParams, + GetAccountsParams, + XrpGetPublicKeyParams, + Account +> & { + signTransaction?: ( + payload: Uint8Array | Buffer, + options?: { path?: DerivationPath }, + ) => Promise; +}; + +export const buildPath = ( + accountIndex: number, + change: number, + addressIndex: number, +): DerivationPath => { + return [ + 44 + HARDENED_OFFSET, + XRP_COIN_TYPE + HARDENED_OFFSET, + accountIndex + HARDENED_OFFSET, + change, + addressIndex, + ]; +}; + +function sha256d(data: Uint8Array): Uint8Array { + return sha256(sha256(data)); +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export const encodeClassicAddress = (accountId: Uint8Array): Address => { + if (accountId.length !== XRP_ACCOUNT_ID_LEN) { + throw new Error(`Invalid XRP account ID length: ${accountId.length}`); + } + const body = new Uint8Array(1 + XRP_ACCOUNT_ID_LEN); + body[0] = XRP_ADDRESS_VERSION; + body.set(accountId, 1); + + const checksum = sha256d(body).slice(0, XRP_CHECKSUM_LEN); + const payload = new Uint8Array(XRP_ADDRESS_BYTES); + payload.set(body, 0); + payload.set(checksum, body.length); + return base58xrp.encode(payload); +}; + +export const decodeClassicAddress = (address: Address): Uint8Array => { + const payload = base58xrp.decode(address); + if (payload.length !== XRP_ADDRESS_BYTES) { + throw new Error(`Invalid XRP address length: ${payload.length}`); + } + const body = payload.slice(0, 1 + XRP_ACCOUNT_ID_LEN); + if (body[0] !== XRP_ADDRESS_VERSION) { + throw new Error(`Unsupported XRP address version: ${body[0]}`); + } + const checksum = payload.slice(1 + XRP_ACCOUNT_ID_LEN); + const expectedChecksum = sha256d(body).slice(0, XRP_CHECKSUM_LEN); + if (!bytesEqual(checksum, expectedChecksum)) { + throw new Error('Invalid XRP address checksum'); + } + return body.slice(1); +}; + +export const pubkeyToAddress = (pubkey: Uint8Array): Address => { + const compressed = compressSecp256k1Pubkey(pubkey); + if (compressed.length !== 33) { + throw new Error(`Invalid secp256k1 pubkey length: ${compressed.length}`); + } + const accountId = ripemd160(sha256(compressed)); + return encodeClassicAddress(accountId); +}; + +const validateAddress = (address: Address): boolean => { + try { + decodeClassicAddress(address); + return true; + } catch { + return false; + } +}; + +const normalizeAddress = (address: Address): Address => { + return encodeClassicAddress(decodeClassicAddress(address)); +}; + +const resolvePath = ( + params?: { + path?: DerivationPath; + accountIndex?: number; + change?: number; + addressIndex?: number; + }, + options?: XrpAdapterOptions, +): DerivationPath => { + if (params?.path) return params.path; + const accountIndex = params?.accountIndex ?? options?.accountIndex ?? 0; + const change = params?.change ?? options?.change ?? 0; + const addressIndex = params?.addressIndex ?? options?.addressIndex ?? 0; + return buildPath(accountIndex, change, addressIndex); +}; + +export const xrp: ChainModule = { + id: 'xrp', + name: 'XRP', + coinType: XRP_COIN_TYPE, + curve: 'secp256k1', + defaultPath: buildPath(0, 0, 0), + supports: { + signTransaction: true, + signMessage: false, + signTypedData: false, + signArbitrary: false, + getPublicKey: true, + }, + create: (signer: Signer, options?: XrpAdapterOptions): XrpAdapter => { + const getPublicKey = async ( + params: XrpGetPublicKeyParams = {}, + ): Promise => { + const path = resolvePath(params, options); + const wantCompressed = params.compressed ?? true; + const pubkey = await signer.getPublicKey(path, { + compressed: wantCompressed, + }); + return wantCompressed ? compressSecp256k1Pubkey(pubkey) : pubkey; + }; + + const getAddress = async (params: XrpGetAddressParams = {}) => { + const pubkey = await getPublicKey({ ...params, compressed: true }); + return pubkeyToAddress(pubkey); + }; + + // `startIndex` maps to XRP address index at m/44'/144'/account'/change/index. + const getAddresses = async (params: GetAccountsParams = {}) => { + const startIndex = params.startIndex ?? 0; + const count = params.count ?? 1; + const accountIndex = options?.accountIndex ?? 0; + const change = params.change ?? options?.change ?? 0; + const addresses: Address[] = []; + + for (let i = 0; i < count; i += 1) { + addresses.push( + await getAddress({ + accountIndex, + change, + addressIndex: startIndex + i, + }), + ); + } + return addresses; + }; + + const getAccount = async (params: XrpGetAddressParams = {}) => { + const address = await getAddress(params); + const path = resolvePath(params, options); + const publicKey = params.includePublicKey + ? await getPublicKey({ ...params, compressed: true }) + : undefined; + return { address, publicKey, path, index: params.addressIndex }; + }; + + const getAccounts = async (params: GetAccountsParams = {}) => { + const startIndex = params.startIndex ?? 0; + const count = params.count ?? 1; + const accountIndex = options?.accountIndex ?? 0; + const change = params.change ?? options?.change ?? 0; + const accounts: Account[] = []; + + for (let i = 0; i < count; i += 1) { + const addressIndex = startIndex + i; + const path = resolvePath( + { accountIndex, change, addressIndex }, + options, + ); + const publicKey = params.includePublicKey + ? await getPublicKey({ path, compressed: true }) + : undefined; + const address = publicKey + ? pubkeyToAddress(publicKey) + : await getAddress({ path }); + + accounts.push({ + address, + publicKey, + path, + index: addressIndex, + }); + } + return accounts; + }; + + const sign = async (request: XrpSignRequest): Promise => { + const path = request.options?.path ?? resolvePath(undefined, options); + const next: XrpSignRequest = { + ...request, + options: { ...(request.options ?? {}), path }, + }; + return signer.sign(next); + }; + + return { + getAddress, + getAddresses, + getPublicKey, + getAccount, + getAccounts, + sign, + validateAddress, + normalizeAddress, + signTransaction: ( + payload: Uint8Array | Buffer, + signOptions?: { path?: DerivationPath }, + ) => sign({ kind: 'transaction', payload, options: signOptions }), + }; + }, + utils: { + buildPath, + compressSecp256k1Pubkey, + encodeClassicAddress, + decodeClassicAddress, + pubkeyToAddress, + validateAddress, + normalizeAddress, + }, +}; diff --git a/packages/chains/xrp/src/devices/lattice.ts b/packages/chains/xrp/src/devices/lattice.ts new file mode 100644 index 00000000..7ac1d9d8 --- /dev/null +++ b/packages/chains/xrp/src/devices/lattice.ts @@ -0,0 +1,152 @@ +import type { + Address, + ChainPlugin, + DerivationPath, + DeviceContext, + PublicKey, + SignResult, +} from '@gridplus/chain-core'; +import { + type Signer as XrpSigner, + type XrpAdapter, + type XrpAdapterOptions, + type XrpSignRequest, + pubkeyToAddress, + xrp, +} from '../chain'; +import { + buildSigResultFromRsv, + compressSecp256k1Pubkey, + toBuffer, +} from './shared'; + +type LatticeXrpContext = DeviceContext & { + constants: { + EXTERNAL: { + GET_ADDR_FLAGS: { + SECP256K1_PUB: number; + }; + SIGNING: { + CURVES: { + SECP256K1: number; + }; + HASHES: { + SHA512HALF: number; + }; + ENCODINGS: { + XRP: number; + }; + }; + }; + }; +}; + +function getLatticeXrpConstants( + context: DeviceContext, +): LatticeXrpContext['constants'] { + const constants = (context as LatticeXrpContext).constants; + const hasNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + + if ( + !hasNumber(constants?.EXTERNAL?.GET_ADDR_FLAGS?.SECP256K1_PUB) || + !hasNumber(constants?.EXTERNAL?.SIGNING?.CURVES?.SECP256K1) || + !hasNumber(constants?.EXTERNAL?.SIGNING?.HASHES?.SHA512HALF) || + !hasNumber(constants?.EXTERNAL?.SIGNING?.ENCODINGS?.XRP) + ) { + throw new Error('Lattice XRP signer requires EXTERNAL constants'); + } + + return constants; +} + +export function createLatticeXrpSigner(context: DeviceContext): XrpSigner { + const { queue } = context; + const { EXTERNAL } = getLatticeXrpConstants(context); + + const getPublicKey = async ( + path: DerivationPath, + opts?: unknown, + ): Promise => { + const res = (await queue((client: any) => + client.getAddresses({ + startPath: path, + n: 1, + flag: EXTERNAL.GET_ADDR_FLAGS.SECP256K1_PUB, + }), + )) as any[]; + + const pub = res?.[0]; + if (!pub) throw new Error('Device did not return a public key'); + + const pubBytes = Buffer.from(pub); + const wantCompressed = + typeof (opts as any)?.compressed === 'boolean' + ? Boolean((opts as any).compressed) + : true; + + return wantCompressed + ? compressSecp256k1Pubkey(new Uint8Array(pubBytes)) + : new Uint8Array(pubBytes); + }; + + const getAddress = async (path: DerivationPath): Promise
=> { + const pubkey = await getPublicKey(path, { compressed: true }); + return pubkeyToAddress(pubkey); + }; + + const sign = async (request: XrpSignRequest): Promise => { + if (request.kind !== 'transaction') { + throw new Error(`Unsupported XRP sign request kind: ${request.kind}`); + } + + const path = (request as any).options?.path as DerivationPath | undefined; + if (!path || path.length < 2) { + throw new Error('XRP sign request missing signer path'); + } + + const signPayload = { + signerPath: path, + curveType: EXTERNAL.SIGNING.CURVES.SECP256K1, + hashType: EXTERNAL.SIGNING.HASHES.SHA512HALF, + encodingType: EXTERNAL.SIGNING.ENCODINGS.XRP, + payload: toBuffer(request.payload as any), + }; + + const res = await queue((client: any) => + client.sign({ data: signPayload }), + ); + const sig = (res as any).sig ?? {}; + const { signature } = buildSigResultFromRsv(sig); + + const pubkey = (res as any).pubkey + ? compressSecp256k1Pubkey( + new Uint8Array(Buffer.from((res as any).pubkey)), + ) + : undefined; + + return { + signature, + publicKey: pubkey, + }; + }; + + return { + getAddress, + getPublicKey, + sign, + }; +} + +export const latticePlugin: ChainPlugin< + LatticeXrpContext, + XrpSignRequest, + XrpAdapter, + XrpAdapterOptions, + XrpSigner +> = { + chainId: xrp.id, + device: 'lattice', + module: xrp, + createSigner: createLatticeXrpSigner, +}; diff --git a/packages/chains/xrp/src/devices/shared.ts b/packages/chains/xrp/src/devices/shared.ts new file mode 100644 index 00000000..dfe16a64 --- /dev/null +++ b/packages/chains/xrp/src/devices/shared.ts @@ -0,0 +1,6 @@ +export { + compressSecp256k1Pubkey, + toBuffer, + parseHexBytes, + buildSigResultFromRsv, +} from '@gridplus/chain-core'; diff --git a/packages/chains/xrp/src/index.ts b/packages/chains/xrp/src/index.ts new file mode 100644 index 00000000..3a4c7024 --- /dev/null +++ b/packages/chains/xrp/src/index.ts @@ -0,0 +1,16 @@ +export { + decodeClassicAddress, + encodeClassicAddress, + pubkeyToAddress, + xrp, +} from './chain'; +export { createLatticeXrpSigner, latticePlugin } from './devices/lattice'; + +export type { + Signer, + XrpAdapter, + XrpAdapterOptions, + XrpGetAddressParams, + XrpGetPublicKeyParams, + XrpSignRequest, +} from './chain'; diff --git a/packages/chains/xrp/tsconfig.json b/packages/chains/xrp/tsconfig.json new file mode 100644 index 00000000..d8636539 --- /dev/null +++ b/packages/chains/xrp/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2022", "DOM"], + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "./src", + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "exclude": ["node_modules"], + "include": ["src"] +} diff --git a/packages/chains/xrp/tsup.config.ts b/packages/chains/xrp/tsup.config.ts new file mode 100644 index 00000000..12a41f8d --- /dev/null +++ b/packages/chains/xrp/tsup.config.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'tsup'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse( + readFileSync(resolve(__dirname, 'package.json'), 'utf-8'), +); + +const external = Object.keys({ + ...(pkg.dependencies ?? {}), + ...(pkg.peerDependencies ?? {}), +}); + +export default defineConfig({ + entry: ['src/index.ts'], + outDir: './dist', + format: ['esm', 'cjs'], + target: 'node20', + sourcemap: true, + clean: true, + bundle: true, + dts: true, + silent: true, + outExtension: ({ format }) => ({ + js: format === 'esm' ? '.mjs' : '.cjs', + }), + external, + tsconfig: './tsconfig.json', +}); diff --git a/packages/docs/docs/addresses.md b/packages/docs/docs/addresses.md index 6060bb08..42caf39a 100644 --- a/packages/docs/docs/addresses.md +++ b/packages/docs/docs/addresses.md @@ -119,6 +119,17 @@ Extended public keys (XPUB/YPUB/ZPUB) allow you to derive addresses without the **Security note**: XPUB/YPUB/ZPUB reveal all public keys and addresses for an account. Share these carefully. ::: +## ✕ XRP Addresses + +XRP uses BIP44 coin type `144'` (`m/44'/144'/0'/0/0` by default). + +```ts +import { fetchXrpAddresses } from 'gridplus-sdk/api/addresses'; + +// Classic XRP addresses (r...) +const addrs = await fetchXrpAddresses({ n: 5, startPathIndex: 0 }); +``` + ## 🗝️ Public Keys In addition to formatted addresses, the Lattice can return public keys on any supported curve for any BIP32 derivation path. diff --git a/packages/docs/docs/chain-modules.md b/packages/docs/docs/chain-modules.md new file mode 100644 index 00000000..b8d2635b --- /dev/null +++ b/packages/docs/docs/chain-modules.md @@ -0,0 +1,36 @@ +--- +id: 'chain-modules' +sidebar_position: 4 +--- + +# 🧱 Chain Modules + +Chain modules provide chain-specific adapters while reusing the same Lattice transport/signing flow. + +## Available Modules + +- `@gridplus/evm` +- `@gridplus/btc` +- `@gridplus/solana` +- `@gridplus/cosmos` +- `@gridplus/xrp` + +## XRP Example + +```ts +import { xrp, type Signer } from '@gridplus/xrp'; + +const signer: Signer = /* provide signer implementation */; +const wallet = xrp.create(signer); + +const address = await wallet.getAddress(); +``` + +For signing, pass the XRPL signing preimage bytes (`STX\0` + canonical serialization): + +```ts +const result = await wallet.sign({ + kind: 'transaction', + payload: xrplPreimageBytes, +}); +``` diff --git a/packages/docs/docs/intro.md b/packages/docs/docs/intro.md index 1489f849..6bca263a 100644 --- a/packages/docs/docs/intro.md +++ b/packages/docs/docs/intro.md @@ -243,6 +243,7 @@ import { fetchBtcSegwitAddresses, // Bitcoin P2WPKH (bc1...) fetchBtcWrappedSegwitAddresses, // Bitcoin P2SH-P2WPKH (3...) fetchSolanaAddresses, // Solana addresses + fetchXrpAddresses, // XRP classic addresses (r...) } from 'gridplus-sdk/api/addresses'; // Each returns an array of address strings @@ -251,6 +252,7 @@ const btcLegacy = await fetchBtcLegacyAddresses(5); const btcSegwit = await fetchBtcSegwitAddresses(5); const btcWrapped = await fetchBtcWrappedSegwitAddresses(5); const solana = await fetchSolanaAddresses(5); +const xrp = await fetchXrpAddresses({ n: 5, startPathIndex: 0 }); ``` ### Step 4: Signing Transactions diff --git a/packages/docs/docs/signing.md b/packages/docs/docs/signing.md index c2dd150a..53cf534f 100644 --- a/packages/docs/docs/signing.md +++ b/packages/docs/docs/signing.md @@ -175,6 +175,7 @@ The SDK automatically detects and applies the appropriate encoding: | ERC20 Transfer | Token, Recipient, Amount | `EVM` with ABI | | Contract Call | Function name, Parameters | `EVM` with ABI | | Solana Transfer | From, To, Lamports | `SOLANA` | +| XRP Transaction | XRP payment fields | `XRP` | | Bitcoin | Inputs, Outputs, Fee | `BTC` | | Raw Message | Hex or ASCII display | `NONE` | @@ -555,3 +556,14 @@ const payload = { const result = await signSolanaTx(payload); // Returns: { tx, txHash, sigs } ``` + +## XRP + +Use `signXrp` for XRPL signing preimages. + +```ts +import { signXrp } from 'gridplus-sdk/api/signing'; + +// XRPL signing preimage bytes: STX\\0 + canonical serialized transaction +const result = await signXrp(xrplPreimageBytes); +``` diff --git a/packages/docs/sidebars.js b/packages/docs/sidebars.js index bc7e4a54..fd9484d2 100644 --- a/packages/docs/sidebars.js +++ b/packages/docs/sidebars.js @@ -37,6 +37,10 @@ const sidebars = { type: 'doc', id: 'signing', }, + { + type: 'doc', + id: 'chain-modules', + }, ], }, { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 65199d94..b4b696d2 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -32,6 +32,7 @@ "e2e-sign-evm-tx": "vitest run ./src/__test__/e2e/signing/evm-tx.test.ts", "e2e-sign-solana": "vitest run ./src/__test__/e2e/signing/solana*", "e2e-sign-cosmos": "vitest run ./src/__test__/e2e/signing/cosmos*", + "e2e-sign-xrp": "vitest run ./src/__test__/e2e/signing/xrp*", "e2e-sign-unformatted": "vitest run ./src/__test__/e2e/signing/unformatted.test.ts", "e2e-api": "vitest run ./src/__test__/e2e/api.test.ts", "e2e-sign-eip712": "vitest run ./src/__test__/e2e/signing/eip712-msg.test.ts" @@ -60,6 +61,7 @@ "@gridplus/cosmos": "workspace:*", "@gridplus/evm": "workspace:*", "@gridplus/solana": "workspace:*", + "@gridplus/xrp": "workspace:*", "@gridplus/types": "workspace:*", "@ethereumjs/common": "^10.0.0", "@ethereumjs/rlp": "^10.0.0", diff --git a/packages/sdk/src/__test__/e2e/general.test.ts b/packages/sdk/src/__test__/e2e/general.test.ts index 021d6e1f..513d1b8d 100644 --- a/packages/sdk/src/__test__/e2e/general.test.ts +++ b/packages/sdk/src/__test__/e2e/general.test.ts @@ -41,6 +41,13 @@ const id = getDeviceId(); describe('General', () => { let client: Client; + const expectErrorMessage = (err: unknown) => { + if (!(err instanceof Error)) { + throw err; + } + expect(err.message).not.toEqual(null); + }; + beforeAll(async () => { client = await setupClient(); }); @@ -104,8 +111,8 @@ describe('General', () => { addrData.startPath[0] = BTC_PURPOSE_P2WPKH; await client.getAddresses(addrData); throw new Error(null); - } catch (err: any) { - expect(err.message).not.toEqual(null); + } catch (err: unknown) { + expectErrorMessage(err); } // Switch to BTC coin. Should work now. addrData.startPath[1] = BTC_COIN; @@ -127,8 +134,8 @@ describe('General', () => { addrData.startPath[0] = 0; // Purpose 0 -- undefined try { addrs = (await client.getAddresses(addrData)) as string[]; - } catch (err: any) { - expect(err.message).not.toEqual(null); + } catch (err: unknown) { + expectErrorMessage(err); } addrData.startPath[0] = BTC_PURPOSE_P2SH_P2WPKH; @@ -137,8 +144,8 @@ describe('General', () => { try { addrs = (await client.getAddresses(addrData)) as string[]; throw new Error(null); - } catch (err: any) { - expect(err.message).not.toEqual(null); + } catch (err: unknown) { + expectErrorMessage(err); } addrData.startPath[1] = BTC_COIN; // Too many addresses (n>10) @@ -146,8 +153,8 @@ describe('General', () => { try { addrs = (await client.getAddresses(addrData)) as string[]; throw new Error(null); - } catch (err: any) { - expect(err.message).not.toEqual(null); + } catch (err: unknown) { + expectErrorMessage(err); } }); @@ -175,7 +182,7 @@ describe('General', () => { await client.sign(req); }); - it('should sign bad transactions', async (ctx: any) => { + it('should sign bad transactions', async (ctx) => { if (process.env.CI === '1') { ctx.skip(); return; diff --git a/packages/sdk/src/__test__/e2e/signing/xrp/xrp.test.ts b/packages/sdk/src/__test__/e2e/signing/xrp/xrp.test.ts new file mode 100644 index 00000000..501cbcc5 --- /dev/null +++ b/packages/sdk/src/__test__/e2e/signing/xrp/xrp.test.ts @@ -0,0 +1,198 @@ +import { createHash } from 'node:crypto'; +import { pubkeyToAddress } from '@gridplus/xrp'; +import { ecdsaVerify } from 'secp256k1'; +import { fetchXrpAddresses, signXrp } from '../../../..'; +import { ensureHexBuffer } from '../../../../util'; +import { setupClient } from '../../../utils/setup'; + +// XRPL Payment preimage copied byte-for-byte from firmware test vector: +// `lattice-firmware/lattice_firmware/src/currencies/currency_tests.c` +const XRP_PAYMENT_SIGN_PREIMAGE_HEX = + '53545800120000228000000024000000016140000000000003e868400000000000000a7321ed5f5ac8b98974a3ca843326d9b88cebd0560177b973ee0b149f782cfaa06dc66a81145b812c9d57731e27a2da8b1830195f88ef32a3b68314b5f762798a53d543a014caf8b297cff8f2f937e8'; +const XRP_PAYMENT_SIGN_PREIMAGE = Buffer.from([ + 0x53, 0x54, 0x58, 0x00, 0x12, 0x00, 0x00, 0x22, 0x80, 0x00, 0x00, 0x00, 0x24, + 0x00, 0x00, 0x00, 0x01, 0x61, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, + 0x68, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x73, 0x21, 0xed, 0x5f, + 0x5a, 0xc8, 0xb9, 0x89, 0x74, 0xa3, 0xca, 0x84, 0x33, 0x26, 0xd9, 0xb8, 0x8c, + 0xeb, 0xd0, 0x56, 0x01, 0x77, 0xb9, 0x73, 0xee, 0x0b, 0x14, 0x9f, 0x78, 0x2c, + 0xfa, 0xa0, 0x6d, 0xc6, 0x6a, 0x81, 0x14, 0x5b, 0x81, 0x2c, 0x9d, 0x57, 0x73, + 0x1e, 0x27, 0xa2, 0xda, 0x8b, 0x18, 0x30, 0x19, 0x5f, 0x88, 0xef, 0x32, 0xa3, + 0xb6, 0x83, 0x14, 0xb5, 0xf7, 0x62, 0x79, 0x8a, 0x53, 0xd5, 0x43, 0xa0, 0x14, + 0xca, 0xf8, 0xb2, 0x97, 0xcf, 0xf8, 0xf2, 0xf9, 0x37, 0xe8, +]); + +// XRPL OfferCreate preimage (XRP/XRP amounts), from firmware vector: +// `lattice-firmware/lattice_firmware/src/currencies/currency_tests.c` +const XRP_OFFER_CREATE_XRP_XRP_PREIMAGE_HEX = + '53545800120007228008000024000000022a6b49d2006440000000004c4b406540000000002625a068400000000000000c7321ed5f5ac8b98974a3ca843326d9b88cebd0560177b973ee0b149f782cfaa06dc66a81145b812c9d57731e27a2da8b1830195f88ef32a3b6'; +const XRP_OFFER_CREATE_XRP_XRP_PREIMAGE = Buffer.from( + XRP_OFFER_CREATE_XRP_XRP_PREIMAGE_HEX, + 'hex', +); + +// XRPL OfferCreate preimage (IOU/XRP amounts), from firmware vector: +// `lattice-firmware/lattice_firmware/src/currencies/currency_tests.c` +const XRP_OFFER_CREATE_IOU_XRP_PREIMAGE_HEX = + '535458001200072280010000240000000964d4838d7ea4c680000000000000000000000000005553440000000000b5f762798a53d543a014caf8b297cff8f2f937e8654000000002faf08068400000000000000c7321ed5f5ac8b98974a3ca843326d9b88cebd0560177b973ee0b149f782cfaa06dc66a81145b812c9d57731e27a2da8b1830195f88ef32a3b6'; +const XRP_OFFER_CREATE_IOU_XRP_PREIMAGE = Buffer.from( + XRP_OFFER_CREATE_IOU_XRP_PREIMAGE_HEX, + 'hex', +); + +// XRPL OfferCancel preimage, from firmware vector: +// `lattice-firmware/lattice_firmware/src/currencies/currency_tests.c` +const XRP_OFFER_CANCEL_PREIMAGE_HEX = + '535458001200082280000000240000000520190000000368400000000000000a7321ed5f5ac8b98974a3ca843326d9b88cebd0560177b973ee0b149f782cfaa06dc66a81145b812c9d57731e27a2da8b1830195f88ef32a3b6'; +const XRP_OFFER_CANCEL_PREIMAGE = Buffer.from( + XRP_OFFER_CANCEL_PREIMAGE_HEX, + 'hex', +); + +const sha512half = (msg: Buffer) => + createHash('sha512').update(msg).digest().subarray(0, 32); +const XRP_E2E_TIMEOUT_MS = Number(process.env.XRP_E2E_TIMEOUT_MS ?? 180000); +const XRP_E2E_TIMEOUT_PADDING_MS = 10000; +const XRP_E2E_TEST_TIMEOUT_MS = XRP_E2E_TIMEOUT_MS + XRP_E2E_TIMEOUT_PADDING_MS; + +const assertValidXrpSignature = async (payload: Buffer) => { + const resp = await signXrp(payload); + expect(resp.sig).toBeTruthy(); + expect(resp.pubkey).toBeTruthy(); + + const r = ensureHexBuffer(resp.sig?.r as Buffer | string, false); + const s = ensureHexBuffer(resp.sig?.s as Buffer | string, false); + const signature = Buffer.concat([r, s]); + expect(signature.length).toEqual(64); + + const digest = sha512half(payload); + const pubkey = Buffer.from(resp.pubkey as Buffer); + const isValid = ecdsaVerify(signature, digest, pubkey); + expect(isValid).toEqual(true); +}; + +describe('[XRP]', () => { + let supportsXrp = true; + + beforeAll(async () => { + expect(XRP_PAYMENT_SIGN_PREIMAGE.toString('hex')).toEqual( + XRP_PAYMENT_SIGN_PREIMAGE_HEX, + ); + expect(XRP_OFFER_CREATE_XRP_XRP_PREIMAGE.toString('hex')).toEqual( + XRP_OFFER_CREATE_XRP_XRP_PREIMAGE_HEX, + ); + expect(XRP_OFFER_CREATE_IOU_XRP_PREIMAGE.toString('hex')).toEqual( + XRP_OFFER_CREATE_IOU_XRP_PREIMAGE_HEX, + ); + expect(XRP_OFFER_CANCEL_PREIMAGE.toString('hex')).toEqual( + XRP_OFFER_CANCEL_PREIMAGE_HEX, + ); + console.info(`[XRP] Test payload hex: ${XRP_PAYMENT_SIGN_PREIMAGE_HEX}`); + + const client = await setupClient(); + if (Number.isFinite(XRP_E2E_TIMEOUT_MS) && XRP_E2E_TIMEOUT_MS > 0) { + client.timeout = XRP_E2E_TIMEOUT_MS; + } + console.info(`[XRP] Client timeout(ms): ${client.timeout}`); + + const fw = client.getFwConstants(); + const fwVersion = client.getFwVersion(); + const encodingTypes = (fw?.genericSigning?.encodingTypes ?? {}) as Record< + string, + unknown + >; + const hashTypes = (fw?.genericSigning?.hashTypes ?? {}) as Record< + string, + unknown + >; + const hasXrpEncoding = encodingTypes.XRP !== undefined; + const hasSha512Half = hashTypes.SHA512HALF !== undefined; + + console.info( + `[XRP] Device firmware: ${fwVersion.major}.${fwVersion.minor}.${fwVersion.fix}`, + ); + console.info( + `[XRP] Generic encodings: ${Object.keys(encodingTypes).join(', ')}`, + ); + console.info(`[XRP] Generic hashes: ${Object.keys(hashTypes).join(', ')}`); + console.info( + `[XRP] Capability check -> encoding.XRP=${hasXrpEncoding} hash.SHA512HALF=${hasSha512Half}`, + ); + + supportsXrp = hasXrpEncoding && hasSha512Half; + if (!supportsXrp) { + console.warn( + '[XRP] Firmware v0.18.10+ with XRP encoding + SHA512HALF support is required. Skipping tests.', + ); + } + }); + + it( + 'Should sign a known XRPL payment preimage and produce a valid secp256k1 signature', + async (ctx) => { + if (!supportsXrp) { + ctx.skip(); + return; + } + await assertValidXrpSignature(XRP_PAYMENT_SIGN_PREIMAGE); + }, + XRP_E2E_TEST_TIMEOUT_MS, + ); + + it( + 'Should sign a known XRPL OfferCreate (XRP/XRP) preimage and produce a valid secp256k1 signature', + async (ctx) => { + if (!supportsXrp) { + ctx.skip(); + return; + } + await assertValidXrpSignature(XRP_OFFER_CREATE_XRP_XRP_PREIMAGE); + }, + XRP_E2E_TEST_TIMEOUT_MS, + ); + + it( + 'Should sign a known XRPL OfferCreate (IOU/XRP) preimage and produce a valid secp256k1 signature', + async (ctx) => { + if (!supportsXrp) { + ctx.skip(); + return; + } + await assertValidXrpSignature(XRP_OFFER_CREATE_IOU_XRP_PREIMAGE); + }, + XRP_E2E_TEST_TIMEOUT_MS, + ); + + it( + 'Should sign a known XRPL OfferCancel preimage and produce a valid secp256k1 signature', + async (ctx) => { + if (!supportsXrp) { + ctx.skip(); + return; + } + await assertValidXrpSignature(XRP_OFFER_CANCEL_PREIMAGE); + }, + XRP_E2E_TEST_TIMEOUT_MS, + ); + + it( + 'Should return an XRP address consistent with the signer pubkey at the default path', + async (ctx) => { + if (!supportsXrp) { + ctx.skip(); + return; + } + + const [address] = await fetchXrpAddresses({ n: 1, startPathIndex: 0 }); + expect(typeof address).toEqual('string'); + expect(address.startsWith('r')).toEqual(true); + + const resp = await signXrp(XRP_PAYMENT_SIGN_PREIMAGE); + const pubkey = resp.pubkey as Buffer; + expect(pubkey).toBeTruthy(); + + const derivedAddress = pubkeyToAddress(pubkey); + expect(derivedAddress).toEqual(address); + }, + XRP_E2E_TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/sdk/src/__test__/unit/defaultChainManifest.test.ts b/packages/sdk/src/__test__/unit/defaultChainManifest.test.ts index 73a473b6..321ceee3 100644 --- a/packages/sdk/src/__test__/unit/defaultChainManifest.test.ts +++ b/packages/sdk/src/__test__/unit/defaultChainManifest.test.ts @@ -12,6 +12,7 @@ describe('default chain manifest', () => { 'evm:lattice', 'solana:lattice', 'cosmos:lattice', + 'xrp:lattice', ]), ); }); diff --git a/packages/sdk/src/api/addresses.ts b/packages/sdk/src/api/addresses.ts index d584d61c..939c8437 100644 --- a/packages/sdk/src/api/addresses.ts +++ b/packages/sdk/src/api/addresses.ts @@ -11,6 +11,7 @@ import { LEDGER_LIVE_DERIVATION, MAX_ADDR, SOLANA_DERIVATION, + XRP_DERIVATION, } from '../constants'; import { useChain } from '../chains'; import type { GetAddressesRequestParams, WalletPath } from '../types'; @@ -131,6 +132,25 @@ export const fetchSolanaAddresses = async ( }); }; +export const fetchXrpAddresses = async ( + { n, startPathIndex }: FetchAddressesParams = { + n: MAX_ADDR, + startPathIndex: 0, + }, +) => { + const adapter = await useChain('xrp', { + adapterOptions: { + accountIndex: XRP_DERIVATION[2] - HARDENED_OFFSET, + change: XRP_DERIVATION[3], + }, + }); + return adapter.getAddresses({ + startIndex: startPathIndex, + count: n, + change: XRP_DERIVATION[3], + }); +}; + export const fetchLedgerLiveAddresses = async ( { n, startPathIndex }: FetchAddressesParams = { n: MAX_ADDR, diff --git a/packages/sdk/src/api/signing.ts b/packages/sdk/src/api/signing.ts index 88c3490d..985d3bf7 100644 --- a/packages/sdk/src/api/signing.ts +++ b/packages/sdk/src/api/signing.ts @@ -14,6 +14,7 @@ import { CURRENCIES, DEFAULT_ETH_DERIVATION, SOLANA_DERIVATION, + XRP_DERIVATION, } from '../constants'; import { useChain } from '../chains'; import { fetchDecoder } from '../functions/fetchDecoder'; @@ -357,3 +358,29 @@ export const signCosmos = async ( pubkey: result.publicKey ? Buffer.from(result.publicKey) : undefined, }; }; + +/** + * Sign an XRPL transaction signing preimage. + * The payload should be the exact bytes to sign (typically `STX\\0` + canonical XRPL serialization). + */ +export const signXrp = async ( + payload: Buffer | Uint8Array, + overrides?: SignRequestParams, +): Promise => { + const signerPath = + ((overrides as any)?.data?.signerPath as number[] | undefined) ?? + ((overrides as any)?.signerPath as number[] | undefined) ?? + XRP_DERIVATION; + + const adapter = await useChain('xrp'); + const result = await adapter.sign({ + kind: 'transaction', + payload, + options: { path: signerPath }, + }); + + return { + sig: toLatticeSignature(result.signature), + pubkey: result.publicKey ? Buffer.from(result.publicKey) : undefined, + }; +}; diff --git a/packages/sdk/src/chains/defaultManifest.ts b/packages/sdk/src/chains/defaultManifest.ts index 0cac42fe..b8764e61 100644 --- a/packages/sdk/src/chains/defaultManifest.ts +++ b/packages/sdk/src/chains/defaultManifest.ts @@ -3,6 +3,7 @@ import { latticePlugin as btcLatticePlugin } from '@gridplus/btc'; import { latticePlugin as cosmosLatticePlugin } from '@gridplus/cosmos'; import { latticePlugin as evmLatticePlugin } from '@gridplus/evm'; import { latticePlugin as solanaLatticePlugin } from '@gridplus/solana'; +import { latticePlugin as xrpLatticePlugin } from '@gridplus/xrp'; // Single source of built-in chain plugins shipped by the SDK. export const DEFAULT_CHAIN_PLUGINS: ChainPlugin[] = [ @@ -10,6 +11,7 @@ export const DEFAULT_CHAIN_PLUGINS: ChainPlugin[] = [ cosmosLatticePlugin, evmLatticePlugin, solanaLatticePlugin, + xrpLatticePlugin, ]; // Canonical keys used by tests and setup-time override logic. @@ -18,4 +20,5 @@ export const DEFAULT_CHAIN_PLUGIN_KEYS = [ 'cosmos:lattice', 'evm:lattice', 'solana:lattice', + 'xrp:lattice', ] as const; diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 7c33af2f..91517635 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -34,6 +34,7 @@ export const EXTERNAL = { NONE: LatticeSignHash.none, KECCAK256: LatticeSignHash.keccak256, SHA256: LatticeSignHash.sha256, + SHA512HALF: LatticeSignHash.sha512half, }, CURVES: { SECP256K1: LatticeSignCurve.secp256k1, @@ -48,6 +49,7 @@ export const EXTERNAL = { ETH_DEPOSIT: LatticeSignEncoding.eth_deposit, EIP7702_AUTH: LatticeSignEncoding.eip7702_auth, EIP7702_AUTH_LIST: LatticeSignEncoding.eip7702_auth_list, + XRP: LatticeSignEncoding.xrp, }, BLS_DST: { BLS_DST_NUL: LatticeSignBlsDst.NUL, @@ -126,6 +128,7 @@ const BIP_CONSTANTS = { ETH: HARDENED_OFFSET + 60, BTC: HARDENED_OFFSET, BTC_TESTNET: HARDENED_OFFSET + 1, + XRP: HARDENED_OFFSET + 144, }, } as const; @@ -417,7 +420,11 @@ function getFwVersionConst(v: Buffer): FirmwareConstants { c.genericSigning.baseReqSz = 1552; // See `GENERIC_SIGNING_BASE_MSG_SZ` in firmware c.genericSigning.baseDataSz = 1519; - c.genericSigning.hashTypes = EXTERNAL.SIGNING.HASHES; + c.genericSigning.hashTypes = { + NONE: EXTERNAL.SIGNING.HASHES.NONE, + KECCAK256: EXTERNAL.SIGNING.HASHES.KECCAK256, + SHA256: EXTERNAL.SIGNING.HASHES.SHA256, + }; c.genericSigning.curveTypes = EXTERNAL.SIGNING.CURVES; c.genericSigning.encodingTypes = { NONE: EXTERNAL.SIGNING.ENCODINGS.NONE, @@ -474,11 +481,17 @@ function getFwVersionConst(v: Buffer): FirmwareConstants { } // --- V0.18.10 --- - // V0.18.10 added Cosmos (SIGN_MODE_DIRECT) decoding for generic signing + // V0.18.10 added Cosmos and XRP decoding for generic signing. + // It also added SHA512Half for XRP transaction hashes. if (!legacy && gte(v, [0, 18, 10])) { + c.genericSigning.hashTypes = { + ...c.genericSigning.hashTypes, + SHA512HALF: EXTERNAL.SIGNING.HASHES.SHA512HALF, + }; c.genericSigning.encodingTypes = { ...c.genericSigning.encodingTypes, COSMOS: EXTERNAL.SIGNING.ENCODINGS.COSMOS, + XRP: EXTERNAL.SIGNING.ENCODINGS.XRP, }; } @@ -654,6 +667,15 @@ export const COSMOS_DERIVATION = [ 0, ]; +/** @internal */ +export const XRP_DERIVATION = [ + HARDENED_OFFSET + 44, + HARDENED_OFFSET + 144, + HARDENED_OFFSET, + 0, + 0, +]; + /** @internal */ export const LEDGER_LIVE_DERIVATION = [ HARDENED_OFFSET + 49, diff --git a/packages/sdk/src/util.ts b/packages/sdk/src/util.ts index ee80e6ff..bae1ca2a 100644 --- a/packages/sdk/src/util.ts +++ b/packages/sdk/src/util.ts @@ -136,7 +136,7 @@ export const isValidAssetPath = ( PURPOSES.BTC_WRAPPED_SEGWIT, PURPOSES.BTC_SEGWIT, ]; - const allowedCoins = [COINS.ETH, COINS.BTC, COINS.BTC_TESTNET]; + const allowedCoins = [COINS.ETH, COINS.BTC, COINS.BTC_TESTNET, COINS.XRP]; // These coin types were given to us by MyCrypto. They should be allowed, but we expect // an Ethereum-type address with these coin types. // These all use SLIP44: https://github.com/satoshilabs/slips/blob/master/slip-0044.md @@ -155,7 +155,7 @@ export const isValidAssetPath = ( return ( allowedPurposes.indexOf(path[0]) >= 0 && (allowedCoins.indexOf(path[1]) >= 0 || - allowedMyCryptoCoins.indexOf(path[1] - HARDENED_OFFSET) > 0) + allowedMyCryptoCoins.indexOf(path[1] - HARDENED_OFFSET) >= 0) ); }; diff --git a/packages/types/src/firmware.ts b/packages/types/src/firmware.ts index 13fb0739..527211da 100644 --- a/packages/types/src/firmware.ts +++ b/packages/types/src/firmware.ts @@ -25,6 +25,7 @@ export interface GenericSigningData { NONE: typeof LatticeSignHash.none; KECCAK256: typeof LatticeSignHash.keccak256; SHA256: typeof LatticeSignHash.sha256; + SHA512HALF?: typeof LatticeSignHash.sha512half; }; curveTypes: { SECP256K1: typeof LatticeSignCurve.secp256k1; @@ -36,6 +37,7 @@ export interface GenericSigningData { SOLANA: typeof LatticeSignEncoding.solana; COSMOS?: typeof LatticeSignEncoding.cosmos; EVM?: typeof LatticeSignEncoding.evm; + XRP?: typeof LatticeSignEncoding.xrp; }; } diff --git a/packages/types/src/protocol.ts b/packages/types/src/protocol.ts index 9579cfc7..e7cc1363 100644 --- a/packages/types/src/protocol.ts +++ b/packages/types/src/protocol.ts @@ -68,6 +68,7 @@ export enum LatticeSignHash { none = 0, keccak256 = 1, sha256 = 2, + sha512half = 3, } export enum LatticeSignCurve { @@ -84,6 +85,7 @@ export enum LatticeSignEncoding { eth_deposit = 5, eip7702_auth = 6, eip7702_auth_list = 7, + xrp = 8, } export enum LatticeSignBlsDst { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8e4e525..8f0e74be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,31 @@ importers: specifier: ^5.9.2 version: 5.9.3 + packages/chains/xrp: + dependencies: + '@gridplus/chain-core': + specifier: workspace:* + version: link:../chain-core + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 + '@scure/base': + specifier: ^1.2.6 + version: 1.2.6 + devDependencies: + '@biomejs/biome': + specifier: ^1.9.0 + version: 1.9.4 + '@types/node': + specifier: ^24.10.4 + version: 24.10.4 + tsup: + specifier: ^8.5.0 + version: 8.5.1(@microsoft/api-extractor@7.55.2(@types/node@24.10.4))(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + packages/docs: dependencies: '@docusaurus/core': @@ -248,6 +273,9 @@ importers: '@gridplus/types': specifier: workspace:* version: link:../types + '@gridplus/xrp': + specifier: workspace:* + version: link:../chains/xrp '@metamask/eth-sig-util': specifier: ^8.2.0 version: 8.2.0