diff --git a/modules/account-lib/src/index.ts b/modules/account-lib/src/index.ts index ed8c4c9b48..099950772f 100644 --- a/modules/account-lib/src/index.ts +++ b/modules/account-lib/src/index.ts @@ -347,6 +347,8 @@ const coinMessageBuilderFactoryMap = { tada: Ada.MessageBuilderFactory, sol: Sol.MessageBuilderFactory, tsol: Sol.MessageBuilderFactory, + canton: Canton.MessageBuilderFactory, + tcanton: Canton.MessageBuilderFactory, }; coins diff --git a/modules/sdk-coin-canton/src/canton.ts b/modules/sdk-coin-canton/src/canton.ts index a23ffc6e75..dc5e6ee20f 100644 --- a/modules/sdk-coin-canton/src/canton.ts +++ b/modules/sdk-coin-canton/src/canton.ts @@ -93,6 +93,11 @@ export class Canton extends BaseCoin { return true; } + /** @inheritDoc */ + supportsMessageSigning(): boolean { + return true; + } + /** inherited doc */ getDefaultMultisigType(): MultisigType { return multisigTypes.tss; diff --git a/modules/sdk-coin-canton/src/lib/clearSigning.ts b/modules/sdk-coin-canton/src/lib/clearSigning.ts new file mode 100644 index 0000000000..dd3e3edd58 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/clearSigning.ts @@ -0,0 +1,93 @@ +import { MessageStandardType } from '@bitgo/sdk-core'; +import { PreparedTransaction } from '../../resources/proto/preparedTransaction.js'; +import utils from './utils'; + +/** + * Clear signing and payload-type detection for Canton message signing requests. + * + * The Canton Signing Driver receives raw `tx` bytes and a `txHash` from the + * Canton Gateway's signTransaction() call. It does not know whether `tx` is a + * Daml prepared transaction or a topology transaction. This module owns that + * detection, keeping Canton-specific proto knowledge inside sdk-coin-canton. + * + * Usage in wallet-platform (buildUnsignedMsgWithIntent): + * 1. Call detectCantonSigningPayloadType(tx) → MessageStandardType + * 2. Use that type as messageStandardType in the msgrequest + * 3. In getHsmPayload, key off the type to choose Format 1 vs Format 2 + */ + +export interface DecodedCantonTransaction { + /** 'CreateCommand' or 'ExerciseCommand' */ + kind: string; + /** Fully-qualified Daml template identifier */ + templateId: { + packageId: string; + moduleName: string; + entityName: string; + }; + /** Decoded Daml arguments (create arguments or choice arguments) */ + argument: unknown; + /** Choice name — present for ExerciseCommand only */ + choice?: string; + /** Contract ID being exercised — present for ExerciseCommand only */ + contractId?: string; + /** Parties acting on this command — present for ExerciseCommand only */ + actingParties?: string[]; +} + +/** + * Detect whether a Canton `tx` payload (base64) is a Daml prepared transaction + * or a topology transaction, by attempting to parse as PreparedTransaction proto. + * + * - PreparedTransaction → has `transaction.nodes` populated → CANTON_SIGN_TRANSACTION + * - Topology bytes → parsing fails or nodes empty → CANTON_SIGN_TOPOLOGY + * + * wallet-platform calls this on the raw `tx` bytes from the Canton Gateway to + * determine which MessageStandardType to use and which HSM payload format to apply. + * + * @param txBase64 - base64-encoded `tx` bytes from Canton's signTransaction request + * @returns The appropriate MessageStandardType for this payload + */ +export function detectCantonSigningPayloadType(txBase64: string): MessageStandardType { + try { + const bytes = Buffer.from(txBase64, 'base64'); + const decoded = PreparedTransaction.fromBinary(bytes); + if (decoded.transaction && Array.isArray(decoded.transaction.nodes) && decoded.transaction.nodes.length > 0) { + return MessageStandardType.CANTON_SIGN_TRANSACTION; + } + } catch { + // bytes did not parse as a PreparedTransaction proto + } + return MessageStandardType.CANTON_SIGN_TOPOLOGY; +} + +/** + * Decode a Canton prepared transaction into a human-readable structure + * suitable for storage as a clearSigningPayload on the txRequest message. + * + * Only meaningful when detectCantonSigningPayloadType() returns CANTON_SIGN_TRANSACTION. + * For topology transactions, there is no Daml command structure to decode. + * + * @param preparedTransactionBase64 - base64-encoded protobuf bytes from the Canton signTransaction request + * @returns Decoded command info describing the Daml operation being signed + */ +export function decodePreparedTransaction(preparedTransactionBase64: string): DecodedCantonTransaction { + // Single parse: extractCantonCommandInfo internally calls PreparedTransaction.fromBinary. + // Any failure (topology bytes, random bytes, missing transaction body) is caught here and + // surfaced as a descriptive error rather than letting an internal proto exception escape. + try { + const info = utils.extractCantonCommandInfo(preparedTransactionBase64); + return { + kind: info.kind, + templateId: info.templateId, + argument: info.argument, + ...(info.choice !== undefined && { choice: info.choice }), + ...(info.contractId !== undefined && { contractId: info.contractId }), + ...(info.actingParties !== undefined && { actingParties: info.actingParties }), + }; + } catch { + throw new Error( + 'decodePreparedTransaction: payload is not a Daml PreparedTransaction — call detectCantonSigningPayloadType() first' + ); + } +} diff --git a/modules/sdk-coin-canton/src/lib/index.ts b/modules/sdk-coin-canton/src/lib/index.ts index c7bc40268b..09edd390e9 100644 --- a/modules/sdk-coin-canton/src/lib/index.ts +++ b/modules/sdk-coin-canton/src/lib/index.ts @@ -20,3 +20,5 @@ export { WalletInitBuilder } from './walletInitBuilder'; export { WalletInitTransaction } from './walletInitialization/walletInitTransaction'; export { Utils, Interface }; +export * from './messages'; +export * from './clearSigning'; diff --git a/modules/sdk-coin-canton/src/lib/messages/cantonBaseMessage.ts b/modules/sdk-coin-canton/src/lib/messages/cantonBaseMessage.ts new file mode 100644 index 0000000000..12388c3be0 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/cantonBaseMessage.ts @@ -0,0 +1,24 @@ +import { BaseMessage } from '@bitgo/sdk-core'; + +/** + * Shared base for Canton message types. + * + * Both CANTON_SIGN_TRANSACTION and CANTON_SIGN_TOPOLOGY sign the same thing — + * the raw bytes of a base64-encoded txHash. The only difference between the two + * concrete classes is the MessageStandardType, which wallet-platform uses to + * choose the correct HSM payload format (Format 1 vs Format 2). + * + * Extracting the common getSignablePayload() here prevents the two classes from + * drifting apart if the encoding ever changes. + */ +export abstract class CantonBaseMessage extends BaseMessage { + async getSignablePayload(): Promise { + if (!this.payload) { + throw new Error('Message payload is missing'); + } + // txHash arrives as a base64 string; decode to raw bytes for signing. + // Equivalent to the normal transaction flow: Buffer.from(preparedTransactionHash, 'base64'). + this.signablePayload = Buffer.from(this.payload, 'base64'); + return this.signablePayload; + } +} diff --git a/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/cantonSignTopologyMessage.ts b/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/cantonSignTopologyMessage.ts new file mode 100644 index 0000000000..dc48ae3299 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/cantonSignTopologyMessage.ts @@ -0,0 +1,19 @@ +import { MessageOptions, MessageStandardType } from '@bitgo/sdk-core'; +import { CantonBaseMessage } from '../cantonBaseMessage'; + +/** + * Canton sign-topology message (topology transaction — e.g. party hosting). + * + * Used when the Canton Gateway requests signing of a topology transaction, + * for example when a party is being hosted on an external validator. + * The CANTON_SIGN_TOPOLOGY type tells wallet-platform to apply HSM payload + * Format 1: [txnType=0] || itemCount || [len || topoTxBytes]... || signableHex + */ +export class CantonSignTopologyMessage extends CantonBaseMessage { + constructor(options: MessageOptions) { + super({ + ...options, + type: MessageStandardType.CANTON_SIGN_TOPOLOGY, + }); + } +} diff --git a/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/cantonSignTopologyMessageBuilder.ts b/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/cantonSignTopologyMessageBuilder.ts new file mode 100644 index 0000000000..fe024b7c84 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/cantonSignTopologyMessageBuilder.ts @@ -0,0 +1,23 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BaseMessageBuilder, IMessage, MessageOptions, MessageStandardType } from '@bitgo/sdk-core'; +import { CantonSignTopologyMessage } from './cantonSignTopologyMessage'; + +/** + * Builder for Canton sign-topology messages. + * + * The payload should be the base64-encoded txHash from Canton's signTransaction + * call when it is requesting a topology transaction to be signed (e.g. party + * hosting on an external validator). + * + * wallet-platform uses the CANTON_SIGN_TOPOLOGY type to apply HSM payload + * Format 1 (topology framing) rather than Format 2 (prepared-transaction framing). + */ +export class CantonSignTopologyMessageBuilder extends BaseMessageBuilder { + public constructor(_coinConfig: Readonly) { + super(_coinConfig, MessageStandardType.CANTON_SIGN_TOPOLOGY); + } + + public async buildMessage(options: MessageOptions): Promise { + return new CantonSignTopologyMessage(options); + } +} diff --git a/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/index.ts b/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/index.ts new file mode 100644 index 0000000000..8e3b5ee3b0 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/cantonSignTopology/index.ts @@ -0,0 +1,2 @@ +export * from './cantonSignTopologyMessage'; +export * from './cantonSignTopologyMessageBuilder'; diff --git a/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/cantonSignTransactionMessage.ts b/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/cantonSignTransactionMessage.ts new file mode 100644 index 0000000000..150e290cff --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/cantonSignTransactionMessage.ts @@ -0,0 +1,18 @@ +import { MessageOptions, MessageStandardType } from '@bitgo/sdk-core'; +import { CantonBaseMessage } from '../cantonBaseMessage'; + +/** + * Canton sign-transaction message (Daml prepared transaction). + * + * Used when the Canton Gateway requests signing of a Daml ledger transaction. + * The CANTON_SIGN_TRANSACTION type tells wallet-platform to apply HSM payload + * Format 2: itemCount=2 || len || preparedTxBinary || signableHex + */ +export class CantonSignTransactionMessage extends CantonBaseMessage { + constructor(options: MessageOptions) { + super({ + ...options, + type: MessageStandardType.CANTON_SIGN_TRANSACTION, + }); + } +} diff --git a/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/cantonSignTransactionMessageBuilder.ts b/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/cantonSignTransactionMessageBuilder.ts new file mode 100644 index 0000000000..dcec7cbd0a --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/cantonSignTransactionMessageBuilder.ts @@ -0,0 +1,20 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BaseMessageBuilder, IMessage, MessageOptions, MessageStandardType } from '@bitgo/sdk-core'; +import { CantonSignTransactionMessage } from './cantonSignTransactionMessage'; + +/** + * Builder for Canton sign-transaction messages. + * + * The payload should be the base64-encoded txHash from Canton's + * signTransaction RPC call. The builder produces a CantonSignTransactionMessage + * whose signable payload is the decoded raw bytes of that hash. + */ +export class CantonSignTransactionMessageBuilder extends BaseMessageBuilder { + public constructor(_coinConfig: Readonly) { + super(_coinConfig, MessageStandardType.CANTON_SIGN_TRANSACTION); + } + + public async buildMessage(options: MessageOptions): Promise { + return new CantonSignTransactionMessage(options); + } +} diff --git a/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/index.ts b/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/index.ts new file mode 100644 index 0000000000..f75a0ef21b --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/cantonSignTransaction/index.ts @@ -0,0 +1,2 @@ +export * from './cantonSignTransactionMessage'; +export * from './cantonSignTransactionMessageBuilder'; diff --git a/modules/sdk-coin-canton/src/lib/messages/index.ts b/modules/sdk-coin-canton/src/lib/messages/index.ts new file mode 100644 index 0000000000..91b9251520 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/index.ts @@ -0,0 +1,4 @@ +export * from './cantonBaseMessage'; +export * from './messageBuilderFactory'; +export * from './cantonSignTransaction'; +export * from './cantonSignTopology'; diff --git a/modules/sdk-coin-canton/src/lib/messages/messageBuilderFactory.ts b/modules/sdk-coin-canton/src/lib/messages/messageBuilderFactory.ts new file mode 100644 index 0000000000..ad91af7407 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/messages/messageBuilderFactory.ts @@ -0,0 +1,21 @@ +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { BaseMessageBuilderFactory, IMessageBuilder, MessageStandardType } from '@bitgo/sdk-core'; +import { CantonSignTransactionMessageBuilder } from './cantonSignTransaction'; +import { CantonSignTopologyMessageBuilder } from './cantonSignTopology'; + +export class MessageBuilderFactory extends BaseMessageBuilderFactory { + constructor(coinConfig: Readonly) { + super(coinConfig); + } + + public getMessageBuilder(type: MessageStandardType): IMessageBuilder { + switch (type) { + case MessageStandardType.CANTON_SIGN_TRANSACTION: + return new CantonSignTransactionMessageBuilder(this.coinConfig); + case MessageStandardType.CANTON_SIGN_TOPOLOGY: + return new CantonSignTopologyMessageBuilder(this.coinConfig); + default: + throw new Error(`Invalid message standard ${type}`); + } + } +} diff --git a/modules/sdk-coin-canton/test/unit/canton.ts b/modules/sdk-coin-canton/test/unit/canton.ts index f95337ce18..6720500173 100644 --- a/modules/sdk-coin-canton/test/unit/canton.ts +++ b/modules/sdk-coin-canton/test/unit/canton.ts @@ -42,6 +42,25 @@ function walletWithRootAddress(rootAddress: string): IWallet { return { coinSpecific: () => ({ rootAddress }) } as unknown as IWallet; } +describe('Canton coin:', function () { + let bitgo: TestBitGoAPI; + let basecoin: Canton; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('canton', Canton.createInstance); + bitgo.safeRegister('tcanton', Tcanton.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('tcanton') as Canton; + }); + + describe('supportsMessageSigning', function () { + it('should return true', function () { + basecoin.supportsMessageSigning().should.equal(true); + }); + }); +}); + describe('Canton verifyTransaction:', function () { let bitgo: TestBitGoAPI; let basecoin: Canton; diff --git a/modules/sdk-coin-canton/test/unit/index.ts b/modules/sdk-coin-canton/test/unit/index.ts index c5221b95a5..6e04a56b73 100644 --- a/modules/sdk-coin-canton/test/unit/index.ts +++ b/modules/sdk-coin-canton/test/unit/index.ts @@ -4,6 +4,10 @@ import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test'; import { Canton, Tcanton } from '../../src'; import './canton'; +import './messages/clearSigning'; +import './messages/cantonSignTransactionMessage'; +import './messages/cantonSignTopologyMessage'; +import './messages/messageBuilderFactory'; describe('Canton:', function () { let bitgo: TestBitGoAPI; diff --git a/modules/sdk-coin-canton/test/unit/messages/cantonSignTopologyMessage.ts b/modules/sdk-coin-canton/test/unit/messages/cantonSignTopologyMessage.ts new file mode 100644 index 0000000000..26c04701d5 --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/messages/cantonSignTopologyMessage.ts @@ -0,0 +1,47 @@ +import assert from 'assert'; +import should from 'should'; +import { MessageOptions, MessageStandardType } from '@bitgo/sdk-core'; +import { CantonSignTopologyMessage } from '../../../src/lib/messages/cantonSignTopology/cantonSignTopologyMessage'; + +// A real base64-encoded topology hash (multiHash from GenerateTopologyResponse). +const SAMPLE_TOPOLOGY_HASH = 'EiDQky+Uxo2zEwFp+JabeazILMMd7QR639/B/u+OGR+npg=='; + +function makeOptions(payload = SAMPLE_TOPOLOGY_HASH): MessageOptions { + return { coinConfig: {} as any, payload }; +} + +describe('CantonSignTopologyMessage', function () { + describe('constructor', function () { + it('should set type to CANTON_SIGN_TOPOLOGY', function () { + const msg = new CantonSignTopologyMessage(makeOptions()); + msg.getType().should.equal(MessageStandardType.CANTON_SIGN_TOPOLOGY); + }); + + it('should store the payload', function () { + const msg = new CantonSignTopologyMessage(makeOptions()); + msg.getPayload().should.equal(SAMPLE_TOPOLOGY_HASH); + }); + }); + + describe('getSignablePayload', function () { + it('should decode base64 topology hash to raw bytes', async function () { + const msg = new CantonSignTopologyMessage(makeOptions()); + const signable = await msg.getSignablePayload(); + should.ok(Buffer.isBuffer(signable)); + assert.deepStrictEqual(signable as Buffer, Buffer.from(SAMPLE_TOPOLOGY_HASH, 'base64')); + }); + + it('should produce same byte structure as CANTON_SIGN_TRANSACTION for equivalent hash', async function () { + // Both message types decode base64 → raw bytes identically; the type is the HSM format discriminator + const hash = 'EiDQky+Uxo2zEwFp+JabeazILMMd7QR639/B/u+OGR+npg=='; + const msg = new CantonSignTopologyMessage({ coinConfig: {} as any, payload: hash }); + const signable = await msg.getSignablePayload(); + assert.deepStrictEqual(signable as Buffer, Buffer.from(hash, 'base64')); + }); + + it('should throw when payload is missing', async function () { + const msg = new CantonSignTopologyMessage({ ...makeOptions(), payload: '' }); + await msg.getSignablePayload().should.be.rejectedWith('Message payload is missing'); + }); + }); +}); diff --git a/modules/sdk-coin-canton/test/unit/messages/cantonSignTransactionMessage.ts b/modules/sdk-coin-canton/test/unit/messages/cantonSignTransactionMessage.ts new file mode 100644 index 0000000000..071b4426ff --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/messages/cantonSignTransactionMessage.ts @@ -0,0 +1,49 @@ +import assert from 'assert'; +import should from 'should'; +import { MessageOptions, MessageStandardType } from '@bitgo/sdk-core'; +import { CantonSignTransactionMessage } from '../../../src/lib/messages/cantonSignTransaction/cantonSignTransactionMessage'; + +// A real base64-encoded preparedTransactionHash from Canton's prepareSubmission response. +// This is exactly what Canton's signTransaction sends as txHash. +const SAMPLE_TX_HASH = '7Ey4Q2TqWQcK1eAl6p15UT02M4mx92Tvo9ifvtzlm5o='; + +function makeOptions(payload = SAMPLE_TX_HASH): MessageOptions { + return { coinConfig: {} as any, payload }; +} + +describe('CantonSignTransactionMessage', function () { + describe('constructor', function () { + it('should set type to CANTON_SIGN_TRANSACTION', function () { + const msg = new CantonSignTransactionMessage(makeOptions()); + msg.getType().should.equal(MessageStandardType.CANTON_SIGN_TRANSACTION); + }); + + it('should store the payload', function () { + const msg = new CantonSignTransactionMessage(makeOptions()); + msg.getPayload().should.equal(SAMPLE_TX_HASH); + }); + }); + + describe('getSignablePayload', function () { + it('should decode base64 txHash to raw bytes', async function () { + const msg = new CantonSignTransactionMessage(makeOptions()); + const signable = await msg.getSignablePayload(); + should.ok(Buffer.isBuffer(signable)); + // Decoding the base64 txHash and re-encoding should round-trip + assert.deepStrictEqual(signable as Buffer, Buffer.from(SAMPLE_TX_HASH, 'base64')); + }); + + it('should produce the same bytes as the transaction signablePayload in normal tx flow', async function () { + // In the normal tx flow: Buffer.from(preparedTransactionHash, 'base64') + // txHash IS the preparedTransactionHash — they must be identical + const msg = new CantonSignTransactionMessage(makeOptions()); + const signable = await msg.getSignablePayload(); + assert.deepStrictEqual(signable as Buffer, Buffer.from(SAMPLE_TX_HASH, 'base64')); + }); + + it('should throw when payload is missing', async function () { + const msg = new CantonSignTransactionMessage({ ...makeOptions(), payload: '' }); + await msg.getSignablePayload().should.be.rejectedWith('Message payload is missing'); + }); + }); +}); diff --git a/modules/sdk-coin-canton/test/unit/messages/clearSigning.ts b/modules/sdk-coin-canton/test/unit/messages/clearSigning.ts new file mode 100644 index 0000000000..8abc658a06 --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/messages/clearSigning.ts @@ -0,0 +1,86 @@ +import assert from 'assert'; +import should from 'should'; +import { MessageStandardType } from '@bitgo/sdk-core'; +import { detectCantonSigningPayloadType, decodePreparedTransaction } from '../../../src/lib/clearSigning'; +import { PreparedTransactionRawData, GenerateTopologyResponse } from '../../resources'; + +// A single topology transaction byte string — not a PreparedTransaction proto +const TOPOLOGY_TX = GenerateTopologyResponse.topologyTransactions[0]; + +describe('Canton clearSigning', function () { + describe('detectCantonSigningPayloadType', function () { + it('should detect a Daml prepared transaction as CANTON_SIGN_TRANSACTION', function () { + const type = detectCantonSigningPayloadType(PreparedTransactionRawData); + type.should.equal(MessageStandardType.CANTON_SIGN_TRANSACTION); + }); + + it('should detect a topology transaction as CANTON_SIGN_TOPOLOGY', function () { + const type = detectCantonSigningPayloadType(TOPOLOGY_TX); + type.should.equal(MessageStandardType.CANTON_SIGN_TOPOLOGY); + }); + + it('should return CANTON_SIGN_TOPOLOGY for bytes that fail proto parsing', function () { + // Random base64 that is not a valid PreparedTransaction proto + const randomBytes = Buffer.from('not-a-valid-proto-payload').toString('base64'); + const type = detectCantonSigningPayloadType(randomBytes); + type.should.equal(MessageStandardType.CANTON_SIGN_TOPOLOGY); + }); + + it('should return CANTON_SIGN_TOPOLOGY for empty bytes', function () { + const type = detectCantonSigningPayloadType(Buffer.alloc(0).toString('base64')); + type.should.equal(MessageStandardType.CANTON_SIGN_TOPOLOGY); + }); + }); + + describe('decodePreparedTransaction — invalid input', function () { + it('should throw a descriptive error when called with topology bytes', function () { + should.throws( + () => decodePreparedTransaction(TOPOLOGY_TX), + /not a Daml PreparedTransaction.*detectCantonSigningPayloadType/ + ); + }); + + it('should throw a descriptive error when called with random bytes', function () { + const randomBytes = Buffer.from('not-a-valid-proto-payload').toString('base64'); + should.throws( + () => decodePreparedTransaction(randomBytes), + /not a Daml PreparedTransaction.*detectCantonSigningPayloadType/ + ); + }); + }); + + describe('decodePreparedTransaction', function () { + it('should decode a prepared transaction and return structured command info', function () { + const decoded = decodePreparedTransaction(PreparedTransactionRawData); + should.exist(decoded); + should.exist(decoded.kind); + should.exist(decoded.templateId); + should.exist(decoded.templateId.moduleName); + should.exist(decoded.templateId.entityName); + should.exist(decoded.argument); + }); + + it('decoded templateId should have module and entity name fields', function () { + const decoded = decodePreparedTransaction(PreparedTransactionRawData); + decoded.templateId.moduleName.should.be.a.String().and.not.empty(); + decoded.templateId.entityName.should.be.a.String().and.not.empty(); + }); + + it('decoded kind should be CreateCommand or ExerciseCommand', function () { + const decoded = decodePreparedTransaction(PreparedTransactionRawData); + assert.ok( + decoded.kind === 'CreateCommand' || decoded.kind === 'ExerciseCommand', + `expected CreateCommand or ExerciseCommand, got: ${decoded.kind}` + ); + }); + + it('ExerciseCommand should include choice and actingParties', function () { + const decoded = decodePreparedTransaction(PreparedTransactionRawData); + if (decoded.kind === 'ExerciseCommand') { + should.exist(decoded.choice); + should.exist(decoded.actingParties); + decoded.actingParties!.should.be.an.Array(); + } + }); + }); +}); diff --git a/modules/sdk-coin-canton/test/unit/messages/messageBuilderFactory.ts b/modules/sdk-coin-canton/test/unit/messages/messageBuilderFactory.ts new file mode 100644 index 0000000000..9989dcf5dc --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/messages/messageBuilderFactory.ts @@ -0,0 +1,57 @@ +import should from 'should'; +import { BaseCoin } from '@bitgo/statics'; +import { MessageStandardType } from '@bitgo/sdk-core'; +import { MessageBuilderFactory } from '../../../src/lib/messages/messageBuilderFactory'; +import { CantonSignTransactionMessageBuilder } from '../../../src/lib/messages/cantonSignTransaction/cantonSignTransactionMessageBuilder'; +import { CantonSignTopologyMessageBuilder } from '../../../src/lib/messages/cantonSignTopology/cantonSignTopologyMessageBuilder'; + +describe('Canton MessageBuilderFactory', function () { + let factory: MessageBuilderFactory; + + beforeEach(function () { + factory = new MessageBuilderFactory({ name: 'canton' } as BaseCoin); + }); + + describe('getMessageBuilder', function () { + it('should return CantonSignTransactionMessageBuilder for CANTON_SIGN_TRANSACTION', function () { + const builder = factory.getMessageBuilder(MessageStandardType.CANTON_SIGN_TRANSACTION); + should.exist(builder); + builder.should.be.instanceof(CantonSignTransactionMessageBuilder); + }); + + it('should return CantonSignTopologyMessageBuilder for CANTON_SIGN_TOPOLOGY', function () { + const builder = factory.getMessageBuilder(MessageStandardType.CANTON_SIGN_TOPOLOGY); + should.exist(builder); + builder.should.be.instanceof(CantonSignTopologyMessageBuilder); + }); + + it('should throw for unsupported message standard types', function () { + should.throws(() => factory.getMessageBuilder(MessageStandardType.SIMPLE), /Invalid message standard SIMPLE/); + should.throws(() => factory.getMessageBuilder(MessageStandardType.EIP191), /Invalid message standard EIP191/); + should.throws( + () => factory.getMessageBuilder('UNSUPPORTED' as MessageStandardType), + /Invalid message standard UNSUPPORTED/ + ); + }); + + it('should build a message with CANTON_SIGN_TRANSACTION that returns correct signable payload', async function () { + const txHash = '7Ey4Q2TqWQcK1eAl6p15UT02M4mx92Tvo9ifvtzlm5o='; + const builder = factory.getMessageBuilder(MessageStandardType.CANTON_SIGN_TRANSACTION); + builder.setPayload(txHash); + const message = await builder.build(); + const signable = await message.getSignablePayload(); + should.ok(Buffer.isBuffer(signable)); + (signable as Buffer).should.deepEqual(Buffer.from(txHash, 'base64')); + }); + + it('should build a message with CANTON_SIGN_TOPOLOGY that returns correct signable payload', async function () { + const topoHash = 'EiDQky+Uxo2zEwFp+JabeazILMMd7QR639/B/u+OGR+npg=='; + const builder = factory.getMessageBuilder(MessageStandardType.CANTON_SIGN_TOPOLOGY); + builder.setPayload(topoHash); + const message = await builder.build(); + const signable = await message.getSignablePayload(); + should.ok(Buffer.isBuffer(signable)); + (signable as Buffer).should.deepEqual(Buffer.from(topoHash, 'base64')); + }); + }); +}); diff --git a/modules/sdk-core/src/bitgo/utils/messageTypes.ts b/modules/sdk-core/src/bitgo/utils/messageTypes.ts index 1a322bd5fa..a0f16ed647 100644 --- a/modules/sdk-core/src/bitgo/utils/messageTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/messageTypes.ts @@ -10,6 +10,8 @@ export enum MessageStandardType { EIP191 = 'EIP191', EIP712 = 'EIP712', CIP8 = 'CIP8', + CANTON_SIGN_TRANSACTION = 'CANTON_SIGN_TRANSACTION', + CANTON_SIGN_TOPOLOGY = 'CANTON_SIGN_TOPOLOGY', } export type MessagePayload = string;