From 68b4588fa0e7154e127ee2f232b5e07532a902be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Friedemann=20F=C3=BCrst?= <59653747+friedemannf@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:50:39 +0200 Subject: [PATCH 1/4] feat(canton): add Ledger signing support --- ccip-cli/package.json | 3 + ccip-cli/src/index.ts | 2 +- ccip-cli/src/providers/canton.test.ts | 77 +++--- ccip-cli/src/providers/canton/wallet.ts | 302 ++++++++++++++++++++++-- ccip-cli/src/providers/index.ts | 2 +- ccip-sdk/src/api/index.ts | 2 +- ccip-sdk/src/canton/index.ts | 16 +- ccip-sdk/src/canton/types.ts | 17 +- ccip-sdk/src/index.ts | 2 +- package-lock.json | 186 +++++++++++++++ 10 files changed, 530 insertions(+), 79 deletions(-) diff --git a/ccip-cli/package.json b/ccip-cli/package.json index 17d51b197..3aa77087b 100644 --- a/ccip-cli/package.json +++ b/ccip-cli/package.json @@ -55,7 +55,9 @@ "@coral-xyz/anchor": "^0.29.0", "@ethers-ext/signer-ledger": "^6.0.0-beta.1", "@inquirer/prompts": "8.7.0", + "@ledgerhq/hw-transport": "6.35.7", "@ledgerhq/hw-app-aptos": "6.37.0", + "@ledgerhq/hw-app-canton": "0.12.6", "@ledgerhq/hw-app-solana": "7.9.0", "@ledgerhq/hw-transport-node-hid": "6.32.0", "@mysten/sui": "^2.23.2", @@ -63,6 +65,7 @@ "@ton-community/ton-ledger": "^7.3.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^16.3.0", + "bip32-path": "^0.4.2", "bs58": "^6.0.0", "ethers": "6.17.0", "type-fest": "^5.8.0", diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index f313f6a4f..31272e5d2 100755 --- a/ccip-cli/src/index.ts +++ b/ccip-cli/src/index.ts @@ -31,7 +31,7 @@ Error.stackTraceLimit = 50 // show more stack frames for better debugging // generate:nofail // `const VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -const VERSION = '1.13.1-e6a58224' +const VERSION = '1.13.1-9e6a7f16' // generate:end const require = createRequire(import.meta.url) diff --git a/ccip-cli/src/providers/canton.test.ts b/ccip-cli/src/providers/canton.test.ts index 2066a85c4..f4aab6761 100644 --- a/ccip-cli/src/providers/canton.test.ts +++ b/ccip-cli/src/providers/canton.test.ts @@ -141,27 +141,26 @@ describe('canton/signer', () => { describe('Ed25519TransactionSigner.sign()', () => { it('returns a PartySignatures structure', async () => { const signer = new Ed25519TransactionSigner(ZERO_SEED_HEX, PARTY_ID) - const result = await signer.sign(SAMPLE_HASH) + const result = await signer.signTxHash(SAMPLE_HASH) assert.ok(result.signatures, 'result should have signatures array') - assert.equal(result.signatures.length, 1, 'should have one SinglePartySignatures entry') + assert.equal(result.signatures.length, 1, 'should have one Signature entry') }) it('includes the correct party ID', async () => { const signer = new Ed25519TransactionSigner(ZERO_SEED_HEX, PARTY_ID) - const result = await signer.sign(SAMPLE_HASH) + const result = await signer.signTxHash(SAMPLE_HASH) assert.ok(result.signatures[0], 'should have first party signature') - assert.equal(result.signatures[0].party, PARTY_ID, 'party should match constructor argument') + assert.equal(result.party, PARTY_ID, 'party should match constructor argument') }) it('produces a signature with correct properties', async () => { const signer = new Ed25519TransactionSigner(ZERO_SEED_HEX, PARTY_ID) - const result = await signer.sign(SAMPLE_HASH) + const result = await signer.signTxHash(SAMPLE_HASH) assert.ok(result.signatures[0], 'should have first party signature') - assert.ok(result.signatures[0].signatures[0], 'should have first signature') - const sig = result.signatures[0].signatures[0] + const sig = result.signatures[0] assert.equal( sig.format, @@ -184,10 +183,10 @@ describe('canton/signer', () => { it('produces a valid Ed25519 signature (64 bytes)', async () => { const signer = new Ed25519TransactionSigner(ZERO_SEED_HEX, PARTY_ID) - const result = await signer.sign(SAMPLE_HASH) + const result = await signer.signTxHash(SAMPLE_HASH) - assert.ok(result.signatures[0]?.signatures[0], 'should have signature') - const sig = result.signatures[0].signatures[0] + assert.ok(result.signatures[0], 'should have signature') + const sig = result.signatures[0] assert.ok(sig.signature, 'signature field should be present') const sigBytes = Buffer.from(sig.signature, 'base64') @@ -196,10 +195,10 @@ describe('canton/signer', () => { it('produces a cryptographically valid signature', async () => { const signer = new Ed25519TransactionSigner(TEST_SEED_HEX, PARTY_ID) - const result = await signer.sign(SAMPLE_HASH) + const result = await signer.signTxHash(SAMPLE_HASH) - assert.ok(result.signatures[0]?.signatures[0], 'should have signature') - const sig = result.signatures[0].signatures[0] + assert.ok(result.signatures[0], 'should have signature') + const sig = result.signatures[0] assert.ok(sig.signature, 'signature field should be present') const sigBytes = Buffer.from(sig.signature, 'base64') @@ -217,13 +216,13 @@ describe('canton/signer', () => { const hash1 = new Uint8Array(32).fill(0xaa) const hash2 = new Uint8Array(32).fill(0xbb) - const result1 = await signer.sign(hash1) - const result2 = await signer.sign(hash2) + const result1 = await signer.signTxHash(hash1) + const result2 = await signer.signTxHash(hash2) - assert.ok(result1.signatures[0]?.signatures[0]?.signature, 'result1 should have signature') - assert.ok(result2.signatures[0]?.signatures[0]?.signature, 'result2 should have signature') - const sig1 = result1.signatures[0].signatures[0].signature - const sig2 = result2.signatures[0].signatures[0].signature + assert.ok(result1.signatures[0], 'result1 should have signature') + assert.ok(result2.signatures[0], 'result2 should have signature') + const sig1 = result1.signatures[0] + const sig2 = result2.signatures[0] assert.notEqual(sig1, sig2, 'different hashes should produce different signatures') }) @@ -232,13 +231,13 @@ describe('canton/signer', () => { const signer1 = new Ed25519TransactionSigner(ZERO_SEED_HEX, PARTY_ID) const signer2 = new Ed25519TransactionSigner(TEST_SEED_HEX, PARTY_ID) - const result1 = await signer1.sign(SAMPLE_HASH) - const result2 = await signer2.sign(SAMPLE_HASH) + const result1 = await signer1.signTxHash(SAMPLE_HASH) + const result2 = await signer2.signTxHash(SAMPLE_HASH) - assert.ok(result1.signatures[0]?.signatures[0]?.signature, 'result1 should have signature') - assert.ok(result2.signatures[0]?.signatures[0]?.signature, 'result2 should have signature') - const sig1 = result1.signatures[0].signatures[0].signature - const sig2 = result2.signatures[0].signatures[0].signature + assert.ok(result1.signatures[0]?.signature, 'result1 should have signature') + assert.ok(result2.signatures[0]?.signature, 'result2 should have signature') + const sig1 = result1.signatures[0].signature + const sig2 = result2.signatures[0].signature assert.notEqual(sig1, sig2, 'different signers should produce different signatures') }) @@ -247,9 +246,9 @@ describe('canton/signer', () => { const signer = new Ed25519TransactionSigner(ZERO_SEED_HEX, PARTY_ID) const emptyHash = new Uint8Array(0) - const result = await signer.sign(emptyHash) - assert.ok(result.signatures[0]?.signatures[0], 'should have signature') - const sig = result.signatures[0].signatures[0] + const result = await signer.signTxHash(emptyHash) + assert.ok(result.signatures[0], 'should have signature') + const sig = result.signatures[0] assert.ok(sig.signature, 'should produce a signature even for empty input') @@ -282,30 +281,24 @@ describe('canton/signer', () => { const signer1 = new Ed25519TransactionSigner(ZERO_SEED_HEX, PARTY_ID) const signer2 = new Ed25519TransactionSigner(TEST_SEED_HEX, PARTY_ID) - const result1 = await signer1.sign(SAMPLE_HASH) - const result2 = await signer2.sign(SAMPLE_HASH) + const result1 = await signer1.signTxHash(SAMPLE_HASH) + const result2 = await signer2.signTxHash(SAMPLE_HASH) assert.ok(result1.signatures[0], 'result1 should have party signature') assert.ok(result2.signatures[0], 'result2 should have party signature') // Both should have the same party - assert.equal(result1.signatures[0].party, PARTY_ID) - assert.equal(result2.signatures[0].party, PARTY_ID) + assert.equal(result1.party, PARTY_ID) + assert.equal(result2.party, PARTY_ID) - assert.ok(result1.signatures[0].signatures[0], 'result1 should have signature') - assert.ok(result2.signatures[0].signatures[0], 'result2 should have signature') + assert.ok(result1.signatures[0], 'result1 should have signature') + assert.ok(result2.signatures[0], 'result2 should have signature') // But different fingerprints - assert.notEqual( - result1.signatures[0].signatures[0].signedBy, - result2.signatures[0].signatures[0].signedBy, - ) + assert.notEqual(result1.signatures[0].signedBy, result2.signatures[0].signedBy) // And different signatures - assert.notEqual( - result1.signatures[0].signatures[0].signature, - result2.signatures[0].signatures[0].signature, - ) + assert.notEqual(result1.signatures[0].signature, result2.signatures[0].signature) }) }) }) diff --git a/ccip-cli/src/providers/canton/wallet.ts b/ccip-cli/src/providers/canton/wallet.ts index 71a888153..98d966273 100644 --- a/ccip-cli/src/providers/canton/wallet.ts +++ b/ccip-cli/src/providers/canton/wallet.ts @@ -1,9 +1,43 @@ import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto' -import type { Logger, PartySignatures, TransactionSigner } from '@chainlink/ccip-sdk/src/index.ts' +import { + type Logger, + type SinglePartySignatures, + type TransactionSigner, + CCIPArgumentInvalidError, + CCIPInteractiveRequiredError, +} from '@chainlink/ccip-sdk/src/index.ts' +import CantonLedger, { + type CantonAddress, + type CantonSignature, + CLA, + INS, + P2_FIRST, + P2_MORE, + P2_MSG_END, + SIGNATURE_END_BYTE, + SIGNATURE_FRAMING_BYTE, + STATUS, +} from '@ledgerhq/hw-app-canton/lib/index' +import HIDTransport from '@ledgerhq/hw-transport-node-hid' +import { TransportStatusError } from '@ledgerhq/hw-transport/errors' +// @ts-ignore +import BIPPath from 'bip32-path' import { loadCantonConfig } from './config.ts' +// Unexported constants, copied from: +// https://github.com/LedgerHQ/ledger-live/blob/6651fb6c9687afec979ffc7d8eddb0fcded452a7/libs/ledgerjs/packages/hw-app-canton/src/Canton.ts#L34-L40 +const TLV_SIGNATURE_LENGTH = 131 // bytes: [40][64B main][00][40][64B challenge] +const TLV_SIGNATURE_START_OFFSET = 1 // After framing byte +const TLV_SIGNATURE_END_OFFSET = 65 // End of main signature +const TLV_APPLICATION_SIGNATURE_START_OFFSET = 67 // After [00][40] +const TLV_APPLICATION_SIGNATURE_END_OFFSET = 131 // End of application signature +const ED25519_SIGNATURE_BYTE_LENGTH = 64 // bytes + +// Missing P1 parameter to sign raw tx hashes, not present in hw-app-canton: +const P1_SIGN_HASH = 0x00 + /** * Wallet object returned by {@link loadCantonWallet}. * @@ -13,7 +47,216 @@ import { loadCantonConfig } from './config.ts' */ export interface CantonWalletWithSigner { party: string - signer?: Ed25519TransactionSigner + signer?: TransactionSigner +} + +export class CantonLedgerSigner implements TransactionSigner { + private readonly party: string + private readonly derivationPath: string + private readonly cantonSigner: CantonLedger.default + private readonly fingerprint: string + + private constructor(signer: CantonLedger.default, derivationPath: string, party: string) { + this.derivationPath = derivationPath + this.cantonSigner = signer + this.party = party + this.fingerprint = derivePartyFingerprint(party) + } + + static async create(derivationPath: string, party: string) { + const transport = await HIDTransport.default.create() + const signer = new CantonLedger.default(transport) + const ledgerSigner = new CantonLedgerSigner(signer, derivationPath, party) + + // Validate that the key at the derivation path points to the expected/configured party + // by comparing their fingerprints: + let addressResponse: CantonAddress + try { + addressResponse = await ledgerSigner.cantonSigner.getAddress( + ledgerSigner.derivationPath, + false, + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error( + `Error validating Ledger key for derivation path ${derivationPath}: ${message}`, + ) + } + + const expectedFingerprint = normalizeHex(ledgerSigner.fingerprint) + const returnedFingerprint = normalizeHex( + computeCantonFingerprint(decodeLedgerPublicKey(addressResponse.publicKey)), + ) + if (returnedFingerprint !== expectedFingerprint) { + throw new Error( + `Ledger key mismatch for party "${party}" at derivation path ${derivationPath}: expected fingerprint ${expectedFingerprint}, got ${returnedFingerprint}`, + ) + } + + return ledgerSigner + } + + async signTxHash(hash: Uint8Array): Promise { + /* + * This should just use: + const txHashHex = Buffer.from(hash).toString('hex') + const { signature } = await this.cantonSigner.signTransaction(this.derivationPath, txHashHex) + * But hw-app-canton currently uses `signUntypedVersionedMessage` behind `signTxHash` which uses + * the wrong APDU P1 parameter for raw tx hash signing. + * Signing a raw tx hash should use P1_SIGN_HASH = 0x00, not P1_SIGN_UNTYPED_VERSIONED_MESSAGE = 0x01 + * See reference: + * https://github.com/LedgerHQ/app-canton/blob/develop/doc/APDU.md#sign_hash-p1--0x00-example + * Therefore, copying some of the logic from hw-app-canton to send the correct APDU commands for + * signing a raw tx hash while still keeping the instantiated cantonSigner around in case support + * is added in the future. + * */ + + const txHash = Buffer.from(hash) + + // 1. Send the derivation path + const serializedPath = this.serializeBipPath(this.derivationPath) + + const pathResponse = await this.cantonSigner.transport.send( + CLA, + INS.SIGN, + P1_SIGN_HASH, + P2_FIRST | P2_MORE, + serializedPath, + ) + this.checkTransportResponse(pathResponse) + + // 2. Send the transaction hash as a single transaction + const response = await this.cantonSigner.transport.send( + CLA, + INS.SIGN, + P1_SIGN_HASH, + P2_MSG_END, + txHash, + ) + + this.checkTransportResponse(response) + const responseData = this.extractResponseData(response) + const signatureResponse = this.parseSignatureResponse(responseData) + const signatureBytes = Buffer.from(signatureResponse.signature, 'hex') + + return { + party: this.party, + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: signatureBytes.toString('base64'), + signedBy: this.fingerprint, + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + } + } + + /** + * Check transport response for errors and throw appropriate exceptions + * @private + */ + private checkTransportResponse(response: Buffer): void { + const statusCode = response.readUInt16BE(response.length - 2) + + if (statusCode !== STATUS.OK) { + throw new TransportStatusError(statusCode) + } + } + + /** + * Extract response data from transport response + * APDU responses have format: [data][status_code(2_bytes)] + * @private + */ + private extractResponseData(response: Buffer): Buffer { + return response.slice(0, -2) + } + + /** + * Parse signature response - handles both TLV format (onboarding) and single signatures + * @private + */ + private parseSignatureResponse(response: Buffer, challenge?: string): CantonSignature { + // Handle TLV (Type-Length-Value) format: [40][64B main][00][40][64B challenge] = 131 bytes + if ( + response.length === TLV_SIGNATURE_LENGTH && + response.readUInt8(0) === SIGNATURE_FRAMING_BYTE && + response.readUInt8(TLV_SIGNATURE_END_OFFSET) === SIGNATURE_END_BYTE && + response.readUInt8(TLV_APPLICATION_SIGNATURE_START_OFFSET - 1) === SIGNATURE_FRAMING_BYTE + ) { + const signature = response + .slice(TLV_SIGNATURE_START_OFFSET, TLV_SIGNATURE_END_OFFSET) + .toString('hex') + const applicationSignature = response + .slice(TLV_APPLICATION_SIGNATURE_START_OFFSET, TLV_APPLICATION_SIGNATURE_END_OFFSET) + .toString('hex') + + // Include applicationSignature only if challenge was provided in the request + return { + signature, + ...(challenge && { applicationSignature }), + } + } + + // Handle single signature formats - check length before converting to hex + if (response.length === ED25519_SIGNATURE_BYTE_LENGTH) { + // Pure 64-byte Ed25519 signature = 128 hex chars (64 bytes) + return { signature: response.toString('hex') } + } + + if (response.length === ED25519_SIGNATURE_BYTE_LENGTH + 2) { + // Canton-framed signature: [40][64B Ed25519 sig][00] = 66 bytes (132 hex chars) + const cleanedSignature = response.slice(1, -1).toString('hex') + return { signature: cleanedSignature } + } + + // Fallback: return as hex string + return { signature: response.toString('hex') } + } + + /** + * Serialize a BIP-32 path string to a data buffer for Canton BOLOS + * @private + */ + private serializeBipPath(pathString: string): Buffer { + const bipPath = BIPPath.fromString(pathString).toPathArray() + const data = Buffer.alloc(1 + bipPath.length * 4) + + data.writeUInt8(bipPath.length, 0) // Write path length as first byte + bipPath.forEach((segment: any, index: any) => { + data.writeUInt32BE(segment, 1 + index * 4) // Write each segment as 32-bit integer + }) + + return data + } +} + +function derivePartyFingerprint(party: string): string { + const parts = party.split('::') + const fingerprint = parts[parts.length - 1]?.trim() + if (!fingerprint) { + throw new Error(`Canton party "${party}" does not include a key fingerprint`) + } + return fingerprint +} + +function decodeLedgerPublicKey(publicKey: string): Buffer { + const normalized = publicKey.replace(/^0x/i, '').trim() + if (!/^[\da-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0) { + throw new Error('Ledger publicKey is not valid hex') + } + const raw = Buffer.from(normalized, 'hex') + + if (raw.length !== 32) { + throw new Error(`Ledger publicKey has invalid length ${raw.length}, expected 32`) + } + + return raw +} + +function normalizeHex(value: string): string { + return value.trim().replace(/^0x/i, '').toLowerCase() } /** @@ -73,23 +316,19 @@ export class Ed25519TransactionSigner implements TransactionSigner { * Sign a prepared transaction hash. * * @param hash - Raw hash bytes from the prepare response. - * @returns PartySignatures ready for the execute submission request. + * @returns SinglePartySignatures ready for the execute submission request. */ - sign(hash: Uint8Array): Promise { + signTxHash(hash: Uint8Array): Promise { const signature = sign(null, Buffer.from(hash), this.privateKeyObject) return Promise.resolve({ + party: this.party, signatures: [ { - party: this.party, - signatures: [ - { - format: 'SIGNATURE_FORMAT_RAW', - signature: signature.toString('base64'), - signedBy: this.fingerprint, - signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', - }, - ], + format: 'SIGNATURE_FORMAT_RAW', + signature: signature.toString('base64'), + signedBy: this.fingerprint, + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', }, ], }) @@ -168,11 +407,19 @@ function buildEd25519Pkcs8Der(seed: Buffer): Buffer { * JWT-authenticated direct submit (no external signer); the `--wallet` flag * is accepted but ignored on Canton lanes. */ -export function loadCantonWallet( - argv: { wallet?: unknown; cantonConfig?: string }, +export async function loadCantonWallet( + { + cantonConfig, + wallet: walletOpt, + interactive, + }: { + cantonConfig?: string + wallet?: unknown + interactive?: boolean + }, logger?: Logger, -): CantonWalletWithSigner { - const cantonCfg = loadCantonConfig(argv.cantonConfig, logger) +): Promise { + const cantonCfg = loadCantonConfig(cantonConfig, logger) const party = cantonCfg?.party if (!party) { throw new Error( @@ -180,5 +427,26 @@ export function loadCantonWallet( ) } + if (walletOpt) { + if (typeof walletOpt !== 'string') + throw new CCIPArgumentInvalidError('wallet', 'expected a string') + if (walletOpt.startsWith('ledger')) { + if (interactive === false) { + throw new CCIPInteractiveRequiredError('Ledger wallet requires USB interaction', { + recovery: + 'Use a private key or keystore wallet with password env var for non-interactive mode', + }) + } + let derivationPath = walletOpt.split(':')[1] + if (!derivationPath) derivationPath = `m/44'/6767'/0'/0'/0'` + else if (!isNaN(Number(derivationPath))) + derivationPath = `m/44'/6767'/0'/0'/${derivationPath}'` + + const ledgerSigner = await CantonLedgerSigner.create(derivationPath, party) + logger?.info(`Ledger connected for Canton party: ${party}, derivationPath: ${derivationPath}`) + return { party, signer: ledgerSigner } + } + } + return { party } } diff --git a/ccip-cli/src/providers/index.ts b/ccip-cli/src/providers/index.ts index 64cd11592..392c509ad 100644 --- a/ccip-cli/src/providers/index.ts +++ b/ccip-cli/src/providers/index.ts @@ -382,7 +382,7 @@ export async function loadChainWallet( ) return [wallet.getAddress(), wallet] as const case ChainFamily.Canton: { - const cantonWallet = loadCantonWallet(argv, logger) + const cantonWallet = await loadCantonWallet(argv, logger) return [cantonWallet.party, cantonWallet] as const } default: diff --git a/ccip-sdk/src/api/index.ts b/ccip-sdk/src/api/index.ts index f1f681062..97d3623be 100644 --- a/ccip-sdk/src/api/index.ts +++ b/ccip-sdk/src/api/index.ts @@ -63,7 +63,7 @@ export const DEFAULT_TIMEOUT_MS = 30000 /** SDK version string for telemetry header */ // generate:nofail // `export const SDK_VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -export const SDK_VERSION = '1.13.1-e6a58224' +export const SDK_VERSION = '1.13.1-9e6a7f16' // generate:end /** SDK telemetry header name */ diff --git a/ccip-sdk/src/canton/index.ts b/ccip-sdk/src/canton/index.ts index 0759b53d8..0f3e655f7 100644 --- a/ccip-sdk/src/canton/index.ts +++ b/ccip-sdk/src/canton/index.ts @@ -1403,13 +1403,14 @@ export class CantonChain extends Chain { * - **No signer**: delegates to `submitAndWaitForTransaction` (direct submit). * - **With signer**: uses the interactive submission API: * 1. Prepare the transaction (`/v2/interactive-submission/prepare`). - * 2. Decode the hash and call `signer.sign(hashBytes)`. + * 2. Decode the hash and call `signer.signTxHash(hashBytes)`. * 3. Execute the signed transaction (`/v2/interactive-submission/executeAndWaitForTransaction`). */ private async submitCommands( commands: JsCommands, signer?: TransactionSigner, ): Promise { + // If no signer is passed, default to direct submission. if (!signer) { return this.provider.submitAndWaitForTransaction(commands) } @@ -1428,7 +1429,10 @@ export class CantonChain extends Chain { // Step 2 — Sign the hash const hashBytes = getDataBytes(prepareResponse.preparedTransactionHash) - const partySignatures = await signer.sign(hashBytes) + this.logger.info( + `Canton: prepared transaction, signing transaction with hash ${Buffer.from(hashBytes).toString('hex').toUpperCase()}`, + ) + const partySignatures = await signer.signTxHash(hashBytes) // Step 3 — Execute the signed transaction const hashingSchemeVersion = @@ -1437,15 +1441,15 @@ export class CantonChain extends Chain { ? prepareResponse.hashingSchemeVersion : 'HASHING_SCHEME_VERSION_V3' - const executeResponse = await this.provider.executeSubmissionAndWaitForTransaction({ + return await this.provider.executeSubmissionAndWaitForTransaction({ preparedTransaction: prepareResponse.preparedTransaction, - partySignatures, + partySignatures: { + signatures: [partySignatures], + }, deduplicationPeriod: { Empty: {} }, hashingSchemeVersion, submissionId: `ext-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, }) - - return executeResponse } /** diff --git a/ccip-sdk/src/canton/types.ts b/ccip-sdk/src/canton/types.ts index e28b0dec1..80e84e3d3 100644 --- a/ccip-sdk/src/canton/types.ts +++ b/ccip-sdk/src/canton/types.ts @@ -1,13 +1,13 @@ import { CCIPArgumentInvalidError } from '../errors/specialized.ts' import type { ChainFamily } from '../networks.ts' -import type { JsCommands, PartySignatures } from './client/index.ts' +import type { JsCommands, SinglePartySignatures } from './client/index.ts' /** * Signs a prepared Canton transaction hash on behalf of an external party. * * Implementations receive the raw hash bytes (decoded from the base64 * `preparedTransactionHash` returned by the Preparing Participant Node) and - * must return a fully-assembled {@link PartySignatures} structure. + * must return a fully-assembled {@link SinglePartySignatures} structure. * * @example * ```ts @@ -16,13 +16,10 @@ import type { JsCommands, PartySignatures } from './client/index.ts' * const sig = ed25519.sign(hash, privateKey) * return { * signatures: [{ - * party: partyId, - * signatures: [{ - * format: 'CRYPTO_KEY_FORMAT_RAW', - * signature: Buffer.from(sig).toString('base64'), - * signedBy: keyFingerprint, - * signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', - * }], + * format: 'CRYPTO_KEY_FORMAT_RAW', + * signature: Buffer.from(sig).toString('base64'), + * signedBy: keyFingerprint, + * signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', * }], * } * }, @@ -30,7 +27,7 @@ import type { JsCommands, PartySignatures } from './client/index.ts' * ``` */ export interface TransactionSigner { - sign(hash: Uint8Array): Promise + signTxHash(hash: Uint8Array): Promise } /** diff --git a/ccip-sdk/src/index.ts b/ccip-sdk/src/index.ts index 349b85197..b87d5e7ce 100644 --- a/ccip-sdk/src/index.ts +++ b/ccip-sdk/src/index.ts @@ -147,7 +147,7 @@ export type { UnsignedTONTx } from './ton/index.ts' export type { CantonInstrumentId, CantonWallet, - PartySignatures, + SinglePartySignatures, TransactionSigner, UnsignedCantonTx, } from './canton/index.ts' diff --git a/package-lock.json b/package-lock.json index 3435d0ade..f031955ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,13 +91,16 @@ "@ethers-ext/signer-ledger": "^6.0.0-beta.1", "@inquirer/prompts": "8.7.0", "@ledgerhq/hw-app-aptos": "6.37.0", + "@ledgerhq/hw-app-canton": "0.12.6", "@ledgerhq/hw-app-solana": "7.9.0", + "@ledgerhq/hw-transport": "6.35.7", "@ledgerhq/hw-transport-node-hid": "6.32.0", "@mysten/sui": "^2.23.2", "@solana/web3.js": "^1.98.4", "@ton-community/ton-ledger": "^7.3.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^16.3.0", + "bip32-path": "^0.4.2", "bs58": "^6.0.0", "ethers": "6.17.0", "type-fest": "^5.8.0", @@ -115,6 +118,45 @@ "typescript": "7.0.2" } }, + "ccip-cli/node_modules/@ledgerhq/devices": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.17.0.tgz", + "integrity": "sha512-l+rrVQEjR1hSWOLD00LFX4zbS8yB1M/Mb6UYPmSHGO7TmE1CFbZ4CmDJC/kZSl3hdrXCJ+YUNpvaLCcSWDwA+Q==", + "license": "Apache-2.0", + "dependencies": { + "semver": "7.7.3" + } + }, + "ccip-cli/node_modules/@ledgerhq/errors": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-7.0.0.tgz", + "integrity": "sha512-+Q/vykUlNeIxiM+I3cu1B660WLkzlmIsHLTV9QNV5D2/Ocplx3QMg52NYq2X7OAfGQnfH1rQvhn/NrjT+t9wBA==", + "license": "Apache-2.0" + }, + "ccip-cli/node_modules/@ledgerhq/hw-transport": { + "version": "6.35.7", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.35.7.tgz", + "integrity": "sha512-vVhAVQ56+7A5FY5Mr09HY+bmf3H6TXpwsj+/xbadNkjl//e/YzTWfpXk4eBnj3hwk1PQ9Mn6pKFH0BHjS2BKlg==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.17.0", + "@ledgerhq/errors": "^7.0.0", + "@ledgerhq/logs": "^6.17.0", + "events": "^3.3.0" + } + }, + "ccip-cli/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "ccip-cli/node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -7130,6 +7172,57 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@ledgerhq/hw-app-canton": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-canton/-/hw-app-canton-0.12.6.tgz", + "integrity": "sha512-f8LPsC1/AStzxDinaznpZWVp6Dlm2U0WHwWvy2Hh3Y+M7VGqLaMG+cBF7LhkUVKf/zuMq1vN8qNhx44YNagl2g==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/errors": "^7.0.0", + "@ledgerhq/hw-transport": "6.35.7", + "bip32-path": "^0.4.2", + "protobufjs": "7.5.4" + } + }, + "node_modules/@ledgerhq/hw-app-canton/node_modules/@ledgerhq/devices": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/devices/-/devices-8.17.0.tgz", + "integrity": "sha512-l+rrVQEjR1hSWOLD00LFX4zbS8yB1M/Mb6UYPmSHGO7TmE1CFbZ4CmDJC/kZSl3hdrXCJ+YUNpvaLCcSWDwA+Q==", + "license": "Apache-2.0", + "dependencies": { + "semver": "7.7.3" + } + }, + "node_modules/@ledgerhq/hw-app-canton/node_modules/@ledgerhq/errors": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@ledgerhq/errors/-/errors-7.0.0.tgz", + "integrity": "sha512-+Q/vykUlNeIxiM+I3cu1B660WLkzlmIsHLTV9QNV5D2/Ocplx3QMg52NYq2X7OAfGQnfH1rQvhn/NrjT+t9wBA==", + "license": "Apache-2.0" + }, + "node_modules/@ledgerhq/hw-app-canton/node_modules/@ledgerhq/hw-transport": { + "version": "6.35.7", + "resolved": "https://registry.npmjs.org/@ledgerhq/hw-transport/-/hw-transport-6.35.7.tgz", + "integrity": "sha512-vVhAVQ56+7A5FY5Mr09HY+bmf3H6TXpwsj+/xbadNkjl//e/YzTWfpXk4eBnj3hwk1PQ9Mn6pKFH0BHjS2BKlg==", + "license": "Apache-2.0", + "dependencies": { + "@ledgerhq/devices": "8.17.0", + "@ledgerhq/errors": "^7.0.0", + "@ledgerhq/logs": "^6.17.0", + "events": "^3.3.0" + } + }, + "node_modules/@ledgerhq/hw-app-canton/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@ledgerhq/hw-app-eth": { "version": "6.33.0", "resolved": "https://registry.npmjs.org/@ledgerhq/hw-app-eth/-/hw-app-eth-6.33.0.tgz", @@ -9169,6 +9262,69 @@ "@protobuf-ts/runtime": "^2.11.1" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@redocly/ajv": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.3.tgz", @@ -18573,6 +18729,12 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -24851,6 +25013,30 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", From 0be00e045344a4e4f9011e4c499dcb806c3401f6 Mon Sep 17 00:00:00 2001 From: SyedAsadKazmi Date: Tue, 8 Sep 2026 00:04:45 +0530 Subject: [PATCH 2/4] fix: override protobufjs to 7.6.6 --- package-lock.json | 23 ++++++++--------------- package.json | 1 + 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index f031955ab..b491461d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9301,12 +9301,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -25014,24 +25008,23 @@ "license": "ISC" }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" diff --git a/package.json b/package.json index c93ed80f5..4597d217c 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "js-yaml": "5.2.3" }, "postcss": "8.5.26", + "protobufjs": "7.6.6", "uuid": "^11.1.1", "yaml@1": "^1.10.3", "serialize-javascript": "^7.1.0", From 3693551f7e59ffcd065086b4c8bf025915a20013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Friedemann=20F=C3=BCrst?= <59653747+friedemannf@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:25:26 +0200 Subject: [PATCH 3/4] Update derivation path segment increment --- ccip-cli/src/providers/canton/wallet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ccip-cli/src/providers/canton/wallet.ts b/ccip-cli/src/providers/canton/wallet.ts index 98d966273..20c60c7ab 100644 --- a/ccip-cli/src/providers/canton/wallet.ts +++ b/ccip-cli/src/providers/canton/wallet.ts @@ -440,7 +440,7 @@ export async function loadCantonWallet( let derivationPath = walletOpt.split(':')[1] if (!derivationPath) derivationPath = `m/44'/6767'/0'/0'/0'` else if (!isNaN(Number(derivationPath))) - derivationPath = `m/44'/6767'/0'/0'/${derivationPath}'` + derivationPath = `m/44'/6767'/${derivationPath}'/0'/0'` const ledgerSigner = await CantonLedgerSigner.create(derivationPath, party) logger?.info(`Ledger connected for Canton party: ${party}, derivationPath: ${derivationPath}`) From 6b6c105bbd35f4b647d3da3a1df6827901edafca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Friedemann=20F=C3=BCrst?= <59653747+friedemannf@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:28:41 +0200 Subject: [PATCH 4/4] Add todo on prepared transaction decoding --- ccip-sdk/src/canton/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ccip-sdk/src/canton/index.ts b/ccip-sdk/src/canton/index.ts index 0f3e655f7..d6dc22e25 100644 --- a/ccip-sdk/src/canton/index.ts +++ b/ccip-sdk/src/canton/index.ts @@ -1427,6 +1427,13 @@ export class CantonChain extends Chain { ) } + /* + * TODO: this currently trusts the preparing participant node to act honestly. + * While this level of trust is expected on Canton, it is not ideal - we could + * decode the prepared transaction, present it to the user, and compute the + * signing hash ourselves. + * */ + // Step 2 — Sign the hash const hashBytes = getDataBytes(prepareResponse.preparedTransactionHash) this.logger.info(