Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions src/custodians/ripple/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string> {
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
Expand Down
137 changes: 121 additions & 16 deletions src/custodians/ripple/submission/account-key.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
): Promise<string | undefined> {
const account = await client.get<ApiAccount>(
`/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<string> {
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<string> {
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
}
7 changes: 2 additions & 5 deletions src/custodians/ripple/submission/raw-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,12 @@ async function buildManifestEnvelope(options: SignRawOptions): Promise<{
body: Awaited<ReturnType<typeof buildSignManifestIntentBody>>
}> {
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),
Expand Down
5 changes: 2 additions & 3 deletions src/custodians/ripple/submission/raw-sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions test/contract/ripple-custody.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 () => {
Expand Down
13 changes: 8 additions & 5 deletions test/integration/ripple-raw.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading