From ae86376660d491bd5f9b09ebabc62faa1b21d3dd Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 5 Nov 2026 11:56:43 -0500 Subject: [PATCH 1/2] support getPublicKey --- README.md | 8 + src/custodians/ripple/api.ts | 40 +++ .../ripple/submission/account-key.ts | 137 ++++++++-- src/custodians/ripple/submission/raw-flow.ts | 7 +- src/custodians/ripple/submission/raw-sign.ts | 5 +- test/contract/ripple-custody.contract.test.ts | 18 ++ test/unit/ripple-custody/account-key.test.ts | 252 ++++++++++++++---- test/unit/ripple-custody/custody-api.test.ts | 113 ++++++++ test/unit/ripple-custody/raw-sign.test.ts | 2 +- 9 files changed, 510 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 60ea947..0437f3c 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,14 @@ It throws if more than one account matches. That includes one account holding the address on several ledgers, so pass `ledgerId` and/or `domainId` to pick one. +`api.getPublicKey` reads that account's XRPL public key in the form XRPL uses +(compressed, uppercase hex), e.g. to set up a regular key or a signer list: + +```ts +const publicKey = await custody.api.getPublicKey('r...', { ledgerId: 'xrpl' }) +// → '02218AB4…' +``` + `domainId` is optional when creating a `RippleCustody`. If it's left out, the domain holding the `primary` address is looked up when the custodian is created; `custody.domainId` returns it either way. diff --git a/src/custodians/ripple/api.ts b/src/custodians/ripple/api.ts index dc85dc8..d43b328 100644 --- a/src/custodians/ripple/api.ts +++ b/src/custodians/ripple/api.ts @@ -43,6 +43,7 @@ import type { CustodyPageItem, CustodyPaginatedOperationId, } from './pagination.js' +import { requireXrplPublicKey } from './submission/account-key.js' import { postDryRun } from './submission/dry-run.js' import { waitForIntentOutcome } from './submission/intent-polling.js' import type { CustodyHttpClient } from './transport/custody-http-client.js' @@ -324,6 +325,45 @@ export class CustodyApi { return matches[0] } + /** + * Read the XRPL public key of the Custody account that holds an address, in + * the form XRPL uses (`SigningPubKey`): compressed secp256k1, uppercase hex. + * Useful wherever the key itself is needed, e.g. to set up a regular key or a + * multisig signer list. + * + * @param address - The account's XRPL address. + * @param options - Narrow the lookup when the address is on several ledgers + * or in several domains, as for {@link findByAddress}. + * @param options.ledgerId - Only match on this ledger. + * @param options.domainId - Only match in this domain. + * @returns The public key, e.g. `02218AB4…`. + * @throws {@link AccountNotFoundError} if no Custody account holds the address. + * @throws {@link SimpleXRPLError} if more than one account matches, or the + * account has no XRPL key on file yet (e.g. still provisioning). + * @throws {@link UnsupportedInVersionError} if the instance's Custody + * version can't look the account up. + */ + public async getPublicKey( + address: string, + options: CustodyFindByAddressOptions = {}, + ): Promise { + const reference = await this.findByAddress(address, options) + if (reference === undefined) { + throw new AccountNotFoundError(address) + } + const route = CUSTODY_ROUTES.getAccount + this.versionGuard.assertEndpoint( + route.method, + route.path, + 'api.getPublicKey', + ) + return requireXrplPublicKey( + this.client, + reference.domainId, + reference.accountId, + ) + } + /** * Approve an existing intent as the API user — e.g. as an automated approver * under the intent's policy. Fetches the intent, then signs and posts the diff --git a/src/custodians/ripple/submission/account-key.ts b/src/custodians/ripple/submission/account-key.ts index bd763f0..cd3848a 100644 --- a/src/custodians/ripple/submission/account-key.ts +++ b/src/custodians/ripple/submission/account-key.ts @@ -1,37 +1,142 @@ -import { SignerCapabilityError } from '../../../errors.js' +import { createPublicKey } from 'node:crypto' + +import { SignerCapabilityError, SimpleXRPLError } from '../../../errors.js' import type { components } from '../../../generated/custody.js' import type { CustodyHttpClient } from '../transport/custody-http-client.js' type ApiAccount = components['schemas']['Core_ApiAccount'] +/** Custody's id for a vault account's secp256k1 key — its XRPL key. */ +const XRPL_KEY_ID = 'SECP256K1_CUSTODY_1' + +/** An uncompressed secp256k1 point: `0x04`, then 32-byte x and y. */ +const UNCOMPRESSED_POINT_LENGTH = 65 +const UNCOMPRESSED_POINT_PREFIX = 0x04 +const COORDINATE_LENGTH = 32 + +/** + * Compress a secp256k1 point to XRPL's 33-byte form: `02` or `03` (the parity + * of y), then x. + * + * @param x - The x coordinate. + * @param y - The y coordinate. + * @returns The compressed key, uppercase hex. + */ +function compressPoint(x: Buffer, y: Buffer): string { + const prefix = y[y.length - 1] % 2 === 0 ? '02' : '03' + return `${prefix}${x.toString('hex')}`.toUpperCase() +} + /** - * Fetch a Custody account's XRPL public key — needed to build the - * `SigningPubKey`-stamped preimage for raw signing (RippleRaw). - * Custody's vault key information is a union across key strategies, and a - * key's `publicKey` is itself optional (absent until the vault key is - * provisioned), so both are narrowed defensively. + * Convert a public key as Custody encodes it to the form XRPL uses + * (`SigningPubKey`): compressed secp256k1, uppercase hex. + * + * Custody sends vault keys as base64 SPKI DER, which is compressed here; a raw + * uncompressed point is compressed too, and any other raw bytes (an + * already-compressed key) are hex-encoded as they are. + * + * @param base64 - The key, base64. + * @returns The XRPL public key, uppercase hex. + */ +export function toXrplPublicKey(base64: string): string { + const bytes = Buffer.from(base64, 'base64') + try { + const jwk = createPublicKey({ + key: bytes, + format: 'der', + type: 'spki', + }).export({ format: 'jwk' }) + if (jwk.crv === 'secp256k1' && jwk.x !== undefined && jwk.y !== undefined) { + return compressPoint( + Buffer.from(jwk.x, 'base64url'), + Buffer.from(jwk.y, 'base64url'), + ) + } + } catch { + // Not SPKI: fall through to the raw encodings. + } + if ( + bytes.length === UNCOMPRESSED_POINT_LENGTH && + bytes[0] === UNCOMPRESSED_POINT_PREFIX + ) { + return compressPoint( + bytes.subarray(1, 1 + COORDINATE_LENGTH), + bytes.subarray(1 + COORDINATE_LENGTH), + ) + } + return bytes.toString('hex').toUpperCase() +} + +/** + * Read a Custody account's XRPL public key. Vault accounts list their keys in + * `providerDetails.keys`, where the XRPL key is `SECP256K1_CUSTODY_1`; older + * Custody versions only had the now-deprecated `keyInformation.publicKey`, + * which is used when `keys` carries none. * * @param client - The authenticated Custody client. * @param domainId - The Custody domain the account belongs to. * @param accountId - The Custody account UUID. - * @returns The account's XRPL public key, base64-encoded. - * @throws {@link SignerCapabilityError} if the account has no `PublicKey`-type - * key on file (e.g. still provisioning, or an extended/HD key with no single - * signing key to export). + * @returns The XRPL public key (compressed, uppercase hex), or `undefined` + * when the account has none on file yet (e.g. still provisioning). */ -export async function resolveSigningPublicKey( +export async function fetchXrplPublicKey( client: CustodyHttpClient, domainId: string, accountId: string, -): Promise { +): Promise { const account = await client.get( `/v1/domains/${domainId}/accounts/${accountId}`, ) - const { publicKey } = account.data.providerDetails.keyInformation - if (publicKey?.type !== 'PublicKey') { + const details = account.data.providerDetails + const key = + details.keys?.find((entry) => entry.id === XRPL_KEY_ID)?.publicKey ?? + details.keyInformation.publicKey + return key === undefined ? undefined : toXrplPublicKey(key.value) +} + +/** + * Fetch a Custody account's XRPL public key for raw signing, which stamps it + * as `SigningPubKey` in the preimage (RippleRaw). + * + * @param client - The authenticated Custody client. + * @param domainId - The Custody domain the account belongs to. + * @param accountId - The Custody account UUID. + * @returns The account's XRPL public key, uppercase hex. + * @throws {@link SignerCapabilityError} if the account has no key on file yet. + */ +export async function resolveSigningPublicKey( + client: CustodyHttpClient, + domainId: string, + accountId: string, +): Promise { + const publicKey = await fetchXrplPublicKey(client, domainId, accountId) + if (publicKey === undefined) { throw new SignerCapabilityError( - `RippleCustody has no exportable signing public key for account '${accountId}' yet, so raw signing is unavailable for it.`, + `RippleCustody has no XRPL public key on file for account '${accountId}' yet, so raw signing is unavailable for it.`, + ) + } + return publicKey +} + +/** + * Read a Custody account's XRPL public key, for callers that need it to exist. + * + * @param client - The authenticated Custody client. + * @param domainId - The Custody domain the account belongs to. + * @param accountId - The Custody account UUID. + * @returns The account's XRPL public key, uppercase hex. + * @throws {@link SimpleXRPLError} if the account has no key on file yet. + */ +export async function requireXrplPublicKey( + client: CustodyHttpClient, + domainId: string, + accountId: string, +): Promise { + const publicKey = await fetchXrplPublicKey(client, domainId, accountId) + if (publicKey === undefined) { + throw new SimpleXRPLError( + `Custody account ${accountId} has no XRPL public key on file yet (it may still be provisioning)`, ) } - return publicKey.value + return publicKey } diff --git a/src/custodians/ripple/submission/raw-flow.ts b/src/custodians/ripple/submission/raw-flow.ts index b7e6359..5ad5f8c 100644 --- a/src/custodians/ripple/submission/raw-flow.ts +++ b/src/custodians/ripple/submission/raw-flow.ts @@ -75,15 +75,12 @@ async function buildManifestEnvelope(options: SignRawOptions): Promise<{ body: Awaited> }> { const { state, tx, ctx, accountId } = options - const publicKeyBase64 = await resolveSigningPublicKey( + const signingPubKey = await resolveSigningPublicKey( state.client, state.domainId, accountId, ) - const { preparedTx, preimageBase64 } = buildSigningPreimage( - tx, - publicKeyBase64, - ) + const { preparedTx, preimageBase64 } = buildSigningPreimage(tx, signingPubKey) const body = await buildSignManifestIntentBody( state.intentSigner, buildCustomProperties(tx), diff --git a/src/custodians/ripple/submission/raw-sign.ts b/src/custodians/ripple/submission/raw-sign.ts index 774f9f8..9876d22 100644 --- a/src/custodians/ripple/submission/raw-sign.ts +++ b/src/custodians/ripple/submission/raw-sign.ts @@ -21,15 +21,14 @@ function base64ToUpperHex(base64: string): string { * in what gets signed, not just in the final blob. * * @param tx - The fully autofilled transaction (Sequence/Fee/LastLedgerSequence set). - * @param signingPubKeyBase64 - The account's XRPL public key, base64 (Custody's encoding). + * @param signingPubKey - The account's XRPL public key, uppercase hex. * @returns The transaction with `SigningPubKey` stamped, and the base64 preimage * to send Custody as `Core_ManifestContent_Unsafe.value`. */ export function buildSigningPreimage( tx: Transaction, - signingPubKeyBase64: string, + signingPubKey: string, ): { preparedTx: Transaction; preimageBase64: string } { - const signingPubKey = base64ToUpperHex(signingPubKeyBase64) const preparedTx: Transaction = { ...tx, SigningPubKey: signingPubKey } const preimageHex = encodeForSigning(preparedTx) return { diff --git a/test/contract/ripple-custody.contract.test.ts b/test/contract/ripple-custody.contract.test.ts index f22f80b..202c201 100644 --- a/test/contract/ripple-custody.contract.test.ts +++ b/test/contract/ripple-custody.contract.test.ts @@ -7,6 +7,7 @@ import { import { p256 } from '@noble/curves/p256' import { secp256k1 } from '@noble/curves/secp256k1' +import { deriveAddress } from 'xrpl' import type { AccountSet } from 'xrpl' import { KeypairService } from '../../src/custodians/ripple/auth/keypair.service.js' @@ -552,6 +553,23 @@ describeContract('RippleCustody (live Custody sandbox)', () => { LIVE_TIMEOUT_MS, ) + it( + 'api.getPublicKey returns the key the primary address derives from', + async () => { + const { ledgerId } = requirePrimary(await custody.listAccounts()) + + const publicKey = await custody.api.getPublicKey(SANDBOX_PRIMARY, { + ledgerId, + }) + + // An XRPL address is derived from its public key, so the right key + // reproduces the address exactly. + expect(publicKey).toMatch(/^0[23][0-9A-F]{64}$/u) + expect(deriveAddress(publicKey)).toBe(SANDBOX_PRIMARY) + }, + LIVE_TIMEOUT_MS, + ) + it( 'api.dryRunTransaction returns the verdict and fee estimate', async () => { diff --git a/test/unit/ripple-custody/account-key.test.ts b/test/unit/ripple-custody/account-key.test.ts index 7121fcc..330ec12 100644 --- a/test/unit/ripple-custody/account-key.test.ts +++ b/test/unit/ripple-custody/account-key.test.ts @@ -1,69 +1,227 @@ -import { resolveSigningPublicKey } from '../../../src/custodians/ripple/submission/account-key.js' +import { generateKeyPairSync } from 'node:crypto' + +import { secp256k1 } from '@noble/curves/secp256k1' + +import { + fetchXrplPublicKey, + resolveSigningPublicKey, + toXrplPublicKey, +} from '../../../src/custodians/ripple/submission/account-key.js' import { SignerCapabilityError } from '../../../src/errors.js' -import { DOMAIN_ID, apiAccountBody, makeClient, ok } from './test-utils.js' +import { DOMAIN_ID, makeClient, ok } from './test-utils.js' -describe('resolveSigningPublicKey', () => { - it('returns the base64 public key when a PublicKey vault key is on file', async () => { - const { client } = makeClient(() => ok(apiAccountBody('acc-1', 'cHVia2V5'))) +/** A secp256k1 key as Custody sends it (SPKI DER, base64), and its XRPL form. */ +interface Secp256k1Key { + readonly spkiBase64: string + readonly uncompressed: Buffer + readonly xrpl: string +} - await expect( - resolveSigningPublicKey(client, DOMAIN_ID, 'acc-1'), - ).resolves.toBe('cHVia2V5') +/** + * A fresh secp256k1 key, with its XRPL form computed independently. + * + * @returns The key in each encoding. + */ +function secp256k1Key(): Secp256k1Key { + const { publicKey } = generateKeyPairSync('ec', { namedCurve: 'secp256k1' }) + const spki = publicKey.export({ type: 'spki', format: 'der' }) + // The uncompressed point is the last 65 bytes of a secp256k1 SPKI. + const uncompressed = spki.subarray(spki.length - 65) + return { + spkiBase64: spki.toString('base64'), + uncompressed, + xrpl: secp256k1.ProjectivePoint.fromHex(uncompressed) + .toHex(true) + .toUpperCase(), + } +} + +/** + * A `GET /v1/domains/{domainId}/accounts/{accountId}` body. + * + * @param providerDetails - The account's key fields. + * @returns The account body. + */ +function accountWith( + providerDetails: Record, +): Record { + return { + data: { + id: 'acc-1', + domainId: DOMAIN_ID, + alias: '', + providerDetails: { + vaultId: 'vault-1', + keyStrategy: 'VaultHard', + type: 'Vault', + ...providerDetails, + }, + lock: 'Unlocked', + metadata: {}, + }, + signature: '', + signingKey: '', + } +} + +describe('toXrplPublicKey', () => { + it('compresses an SPKI secp256k1 key, for either parity of y', () => { + // Enough keys that both 02 and 03 prefixes all but surely appear. + const keys = Array.from({ length: 16 }, secp256k1Key) + + for (const key of keys) { + expect(toXrplPublicKey(key.spkiBase64)).toBe(key.xrpl) + } + expect(new Set(keys.map((key) => key.xrpl.slice(0, 2)))).toEqual( + new Set(['02', '03']), + ) }) - it('throws SignerCapabilityError when the account has no key on file yet', async () => { + it('compresses a raw uncompressed point', () => { + const key = secp256k1Key() + + expect(toXrplPublicKey(key.uncompressed.toString('base64'))).toBe(key.xrpl) + }) + + it('hex-encodes an already-compressed key as it is', () => { + const key = secp256k1Key() + + expect( + toXrplPublicKey(Buffer.from(key.xrpl, 'hex').toString('base64')), + ).toBe(key.xrpl) + }) +}) + +describe('fetchXrplPublicKey', () => { + it('reads the SECP256K1_CUSTODY_1 key from keys[], ignoring the others', async () => { + const xrplKey = secp256k1Key() + const { client, http } = makeClient(() => + ok( + accountWith({ + keyInformation: { type: 'VaultDerived', derivationPath: 'm/0' }, + keys: [ + { + id: 'ED25519_CUSTODY_1', + type: 'VaultDerived', + derivationPath: 'm/1', + publicKey: { + type: 'ExtendedPublicKey', + value: 'ZWQ=', + chainCode: 'Yw==', + }, + }, + { + id: 'SECP256K1_CUSTODY_1', + type: 'VaultDerived', + derivationPath: 'm/0', + publicKey: { + type: 'ExtendedPublicKey', + value: xrplKey.spkiBase64, + chainCode: 'Yw==', + }, + }, + ], + }), + ), + ) + + await expect(fetchXrplPublicKey(client, DOMAIN_ID, 'acc-1')).resolves.toBe( + xrplKey.xrpl, + ) + expect(http.requests[0]?.url).toContain( + `/v1/domains/${DOMAIN_ID}/accounts/acc-1`, + ) + }) + + it('prefers keys[] over the deprecated keyInformation', async () => { + const current = secp256k1Key() + const deprecated = secp256k1Key() const { client } = makeClient(() => - ok({ - data: { - id: 'acc-1', - domainId: DOMAIN_ID, - alias: '', - providerDetails: { - vaultId: 'vault-1', - keyStrategy: 'VaultHard', - keyInformation: { type: 'VaultDerived' }, - type: 'Vault', + ok( + accountWith({ + keyInformation: { + type: 'VaultDerived', + derivationPath: 'm/0', + publicKey: { type: 'PublicKey', value: deprecated.spkiBase64 }, }, - lock: 'Unlocked', - metadata: {}, - }, - signature: '', - signingKey: '', - }), + keys: [ + { + id: 'SECP256K1_CUSTODY_1', + type: 'VaultReserved', + publicKey: { type: 'PublicKey', value: current.spkiBase64 }, + }, + ], + }), + ), + ) + + await expect(fetchXrplPublicKey(client, DOMAIN_ID, 'acc-1')).resolves.toBe( + current.xrpl, + ) + }) + + it('falls back to keyInformation on instances without keys[]', async () => { + const key = secp256k1Key() + const { client } = makeClient(() => + ok( + accountWith({ + keyInformation: { + type: 'VaultDerived', + derivationPath: 'm/0', + publicKey: { type: 'PublicKey', value: key.spkiBase64 }, + }, + }), + ), + ) + + await expect(fetchXrplPublicKey(client, DOMAIN_ID, 'acc-1')).resolves.toBe( + key.xrpl, + ) + }) + + it('returns undefined when the account has no key on file yet', async () => { + const { client } = makeClient(() => + ok(accountWith({ keyInformation: { type: 'VaultDerived' } })), ) await expect( - resolveSigningPublicKey(client, DOMAIN_ID, 'acc-1'), - ).rejects.toThrow(SignerCapabilityError) + fetchXrplPublicKey(client, DOMAIN_ID, 'acc-1'), + ).resolves.toBeUndefined() }) +}) - it('throws SignerCapabilityError for an extended (non-exportable) public key', async () => { +describe('resolveSigningPublicKey', () => { + it('returns the XRPL key for raw signing', async () => { + const key = secp256k1Key() const { client } = makeClient(() => - ok({ - data: { - id: 'acc-1', - domainId: DOMAIN_ID, - alias: '', - providerDetails: { - vaultId: 'vault-1', - keyStrategy: 'VaultHard', - keyInformation: { + ok( + accountWith({ + keyInformation: { type: 'VaultDerived', derivationPath: 'm/0' }, + keys: [ + { + id: 'SECP256K1_CUSTODY_1', type: 'VaultDerived', + derivationPath: 'm/0', publicKey: { type: 'ExtendedPublicKey', - value: 'x', - chainCode: 'y', + value: key.spkiBase64, + chainCode: 'Yw==', }, }, - type: 'Vault', - }, - lock: 'Unlocked', - metadata: {}, - }, - signature: '', - signingKey: '', - }), + ], + }), + ), + ) + + await expect( + resolveSigningPublicKey(client, DOMAIN_ID, 'acc-1'), + ).resolves.toBe(key.xrpl) + }) + + it('throws SignerCapabilityError when the account has no key on file yet', async () => { + const { client } = makeClient(() => + ok(accountWith({ keyInformation: { type: 'VaultDerived' } })), ) await expect( diff --git a/test/unit/ripple-custody/custody-api.test.ts b/test/unit/ripple-custody/custody-api.test.ts index 8bd8a20..428ec8d 100644 --- a/test/unit/ripple-custody/custody-api.test.ts +++ b/test/unit/ripple-custody/custody-api.test.ts @@ -1,3 +1,6 @@ +import { generateKeyPairSync } from 'node:crypto' + +import { secp256k1 } from '@noble/curves/secp256k1' import type { Payment } from 'xrpl' import { CustodyApi } from '../../../src/custodians/ripple/api.js' @@ -890,3 +893,113 @@ describe('CustodyApi.dryRunTransaction', () => { expect(http.requests).toHaveLength(0) }) }) + +describe('CustodyApi.getPublicKey', () => { + const { publicKey } = generateKeyPairSync('ec', { namedCurve: 'secp256k1' }) + const spki = publicKey.export({ type: 'spki', format: 'der' }) + // Independently computed: the compressed form of the SPKI's point. + const xrplKey = secp256k1.ProjectivePoint.fromHex( + spki.subarray(spki.length - 65), + ) + .toHex(true) + .toUpperCase() + + /** + * The account `addressRef()` points at, with its XRPL key in `keys[]`. + * + * @param keys - The account's vault keys. + * @returns The account body. + */ + function vaultAccount(keys: unknown[]): Record { + return { + data: { + id: 'account-1', + domainId: DOMAIN, + alias: '', + providerDetails: { + vaultId: 'vault-1', + keyStrategy: 'VaultHard', + keyInformation: { type: 'VaultDerived', derivationPath: 'm/0' }, + keys, + type: 'Vault', + }, + lock: 'Unlocked', + metadata: {}, + }, + signature: '', + signingKey: '', + } + } + + it('finds the account by address and returns its compressed XRPL key', async () => { + const { api, http } = apiOn( + routed([ + ['GET', '/v1/addresses', { items: [addressRef()], count: 1 }], + [ + 'GET', + '/accounts/account-1', + vaultAccount([ + { + id: 'SECP256K1_CUSTODY_1', + type: 'VaultDerived', + derivationPath: 'm/0', + publicKey: { + type: 'ExtendedPublicKey', + value: spki.toString('base64'), + chainCode: 'Yw==', + }, + }, + ]), + ], + ]), + ) + + await expect(api.getPublicKey('rHolder')).resolves.toBe(xrplKey) + expect(http.requests.map((request) => request.url)).toEqual([ + `${GATEWAY}/v1/addresses?address=rHolder`, + `${GATEWAY}/v1/domains/${DOMAIN}/accounts/account-1`, + ]) + }) + + it('throws AccountNotFoundError when no account holds the address', async () => { + const { api } = apiOn( + routed([['GET', '/v1/addresses', { items: [], count: 0 }]]), + ) + + await expect(api.getPublicKey('rHolder')).rejects.toBeInstanceOf( + AccountNotFoundError, + ) + }) + + it('throws when the account has no XRPL key on file yet', async () => { + const { api } = apiOn( + routed([ + ['GET', '/v1/addresses', { items: [addressRef()], count: 1 }], + ['GET', '/accounts/account-1', vaultAccount([])], + ]), + ) + + await expect(api.getPublicKey('rHolder')).rejects.toThrow( + 'Custody account account-1 has no XRPL public key on file yet', + ) + }) + + it('asks for ledgerId when the address is on several ledgers', async () => { + const { api } = apiOn( + routed([ + [ + 'GET', + '/v1/addresses', + { + items: [addressRef(), addressRef({ id: 'm', ledgerId: 'xrpl' })], + count: 2, + }, + ], + ]), + ) + + await expect(api.getPublicKey('rHolder')).rejects.toThrow( + /pass ledgerId to pick one/u, + ) + }) +}) diff --git a/test/unit/ripple-custody/raw-sign.test.ts b/test/unit/ripple-custody/raw-sign.test.ts index 0b94383..b1604b3 100644 --- a/test/unit/ripple-custody/raw-sign.test.ts +++ b/test/unit/ripple-custody/raw-sign.test.ts @@ -33,7 +33,7 @@ describe('buildSigningPreimage / assembleSignedTransaction', () => { const { preparedTx, preimageBase64 } = buildSigningPreimage( tx, - hexToBase64(wallet.publicKey), + wallet.publicKey, ) expect(preparedTx.SigningPubKey).toBe(wallet.publicKey) From fff2a24aa7b9cee1ec864f90ad450698b1c37fdc Mon Sep 17 00:00:00 2001 From: Phu Pham Date: Thu, 5 Nov 2026 12:25:01 -0500 Subject: [PATCH 2/2] fix integration test --- test/integration/ripple-raw.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/integration/ripple-raw.test.ts b/test/integration/ripple-raw.test.ts index 045244f..6bb6b86 100644 --- a/test/integration/ripple-raw.test.ts +++ b/test/integration/ripple-raw.test.ts @@ -1,6 +1,7 @@ import { decode } from 'xrpl' import type { Payment, Transaction, TxResponse } from 'xrpl' +import { toXrplPublicKey } from '../../src/custodians/ripple/submission/account-key.js' import { assembleSignedTransaction, buildSigningPreimage, @@ -53,14 +54,16 @@ describe('RippleRaw signing (live testnet)', () => { // custodian signs an externally constructed blob. const autofilled: Transaction = await client.ledger.autofill(payment) - // Custody carries the account's XRPL public key base64-encoded; the - // primitive re-hexes it back onto the preimage's SigningPubKey. - const publicKeyBase64 = Buffer.from(source.publicKey, 'hex').toString( - 'base64', + // Custody carries the account's public key base64-encoded; the SDK + // converts it to XRPL's form before stamping the preimage's + // SigningPubKey, as the raw path does with the key it reads. + const signingPubKey = toXrplPublicKey( + Buffer.from(source.publicKey, 'hex').toString('base64'), ) + expect(signingPubKey).toBe(source.publicKey) const { preparedTx, preimageBase64 } = buildSigningPreimage( autofilled, - publicKeyBase64, + signingPubKey, ) // Stand in for the Custody vault: the funded wallet signs the same