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
2 changes: 2 additions & 0 deletions modules/account-lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@ const coinMessageBuilderFactoryMap = {
tada: Ada.MessageBuilderFactory,
sol: Sol.MessageBuilderFactory,
tsol: Sol.MessageBuilderFactory,
canton: Canton.MessageBuilderFactory,
tcanton: Canton.MessageBuilderFactory,
};

coins
Expand Down
5 changes: 5 additions & 0 deletions modules/sdk-coin-canton/src/canton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export class Canton extends BaseCoin {
return true;
}

/** @inheritDoc */
supportsMessageSigning(): boolean {
return true;
}

/** inherited doc */
getDefaultMultisigType(): MultisigType {
return multisigTypes.tss;
Expand Down
93 changes: 93 additions & 0 deletions modules/sdk-coin-canton/src/lib/clearSigning.ts
Original file line number Diff line number Diff line change
@@ -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'
);
}
}
2 changes: 2 additions & 0 deletions modules/sdk-coin-canton/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ export { WalletInitBuilder } from './walletInitBuilder';
export { WalletInitTransaction } from './walletInitialization/walletInitTransaction';

export { Utils, Interface };
export * from './messages';
export * from './clearSigning';
24 changes: 24 additions & 0 deletions modules/sdk-coin-canton/src/lib/messages/cantonBaseMessage.ts
Original file line number Diff line number Diff line change
@@ -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<string | Buffer> {
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;
}
}
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
super(_coinConfig, MessageStandardType.CANTON_SIGN_TOPOLOGY);
}

public async buildMessage(options: MessageOptions): Promise<IMessage> {
return new CantonSignTopologyMessage(options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './cantonSignTopologyMessage';
export * from './cantonSignTopologyMessageBuilder';
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
super(_coinConfig, MessageStandardType.CANTON_SIGN_TRANSACTION);
}

public async buildMessage(options: MessageOptions): Promise<IMessage> {
return new CantonSignTransactionMessage(options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './cantonSignTransactionMessage';
export * from './cantonSignTransactionMessageBuilder';
4 changes: 4 additions & 0 deletions modules/sdk-coin-canton/src/lib/messages/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './cantonBaseMessage';
export * from './messageBuilderFactory';
export * from './cantonSignTransaction';
export * from './cantonSignTopology';
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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}`);
}
}
}
19 changes: 19 additions & 0 deletions modules/sdk-coin-canton/test/unit/canton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions modules/sdk-coin-canton/test/unit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading