Skip to content

Commit 9dbfb84

Browse files
committed
feat(sdk-coin-canton): add CantonCommand transaction support
Ticket: SCAAS-9316
1 parent eaba1c2 commit 9dbfb84

15 files changed

Lines changed: 1776 additions & 14 deletions

File tree

modules/sdk-coin-canton/src/canton.ts

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import {
22
AuditDecryptedKeyParams,
33
BaseCoin,
44
BitGoBase,
5+
CantonCommand,
6+
CantonCommandParams,
7+
CantonCreateCommand,
8+
CantonExerciseCommand,
59
KeyPair,
610
MPCAlgorithm,
711
MultisigType,
@@ -10,6 +14,7 @@ import {
1014
ParseTransactionOptions,
1115
SignedTransaction,
1216
SignTransactionOptions,
17+
TransactionParams,
1318
TransactionType,
1419
VerifyTransactionOptions,
1520
TransactionExplanation as BaseTransactionExplanation,
@@ -26,7 +31,8 @@ import { auditEddsaPrivateKey } from '@bitgo/sdk-lib-mpc';
2631
import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics';
2732
import { TransactionBuilderFactory } from './lib';
2833
import { KeyPair as CantonKeyPair } from './lib/keyPair';
29-
import { TxData } from './lib/iface';
34+
import { CantonCommandKind, TxData } from './lib/iface';
35+
import { Transaction } from './lib/transaction/transaction';
3036
import utils from './lib/utils';
3137

3238
export interface TransactionExplanation extends BaseTransactionExplanation {
@@ -37,6 +43,10 @@ export interface ExplainTransactionOptions {
3743
txHex: string;
3844
}
3945

46+
export interface CantonTransactionParams extends TransactionParams {
47+
cantonCommandParams?: CantonCommandParams;
48+
}
49+
4050
export class Canton extends BaseCoin {
4151
protected readonly _staticsCoin: Readonly<StaticsBaseCoin>;
4252

@@ -118,6 +128,11 @@ export class Canton extends BaseCoin {
118128
case TransactionType.CosignDelegationProposal:
119129
// There is no input for these type of transactions, so always return true.
120130
return true;
131+
case TransactionType.CantonCommand:
132+
return this.verifyCantonCommandTransaction(
133+
transaction,
134+
(txParams as CantonTransactionParams).cantonCommandParams
135+
);
121136
case TransactionType.OneStepPreApproval:
122137
// Canton is always a TSS wallet. The SDK's buildTokenEnablements passes enableTokens
123138
// through unchanged for TSS wallets (no conversion to recipients), so txParams.enableTokens
@@ -184,6 +199,143 @@ export class Canton extends BaseCoin {
184199
}
185200
}
186201

202+
private verifyCantonCommandTransaction(
203+
transaction: BaseTransaction,
204+
userParams: CantonCommandParams | undefined
205+
): boolean {
206+
if (!userParams) {
207+
return true;
208+
}
209+
210+
const cantonTx = transaction as Transaction;
211+
const rawPrepared = cantonTx.prepareCommand?.preparedTransaction;
212+
if (!rawPrepared) {
213+
throw new Error('CantonCommand verifyTransaction: missing preparedTransaction protobuf on tx prebuild');
214+
}
215+
216+
const decodedCommand = utils.extractCantonCommandInfo(rawPrepared);
217+
const userCommand = userParams.command as Partial<CantonCommand>;
218+
219+
// command kind
220+
const hasCreate = 'CreateCommand' in userCommand && !!userCommand.CreateCommand;
221+
const hasExercise = 'ExerciseCommand' in userCommand && !!userCommand.ExerciseCommand;
222+
if (hasCreate && hasExercise) {
223+
throw new Error(
224+
`CantonCommand verifyTransaction: user command must contain exactly one of CreateCommand or ExerciseCommand, not both`
225+
);
226+
}
227+
let userKind: CantonCommandKind;
228+
if (hasCreate) {
229+
userKind = 'CreateCommand';
230+
} else if (hasExercise) {
231+
userKind = 'ExerciseCommand';
232+
} else {
233+
throw new Error(
234+
`CantonCommand verifyTransaction: user command must contain a CreateCommand or ExerciseCommand wrapper`
235+
);
236+
}
237+
if (decodedCommand.kind !== userKind) {
238+
throw new Error(
239+
`CantonCommand verifyTransaction: command kind mismatch — expected ${userKind}, got ${decodedCommand.kind}`
240+
);
241+
}
242+
243+
const userInner =
244+
userKind === 'CreateCommand'
245+
? (userCommand as CantonCreateCommand).CreateCommand
246+
: (userCommand as CantonExerciseCommand).ExerciseCommand;
247+
248+
// templateId (moduleName + entityName; package id is mutable, so ignored)
249+
const parsed = utils.parseCantonTemplateId(userInner.templateId);
250+
if (!parsed) {
251+
throw new Error(
252+
`CantonCommand verifyTransaction: invalid user templateId '${userInner.templateId}' — expected format 'Pkg:Module:Entity'`
253+
);
254+
}
255+
if (
256+
decodedCommand.templateId.moduleName !== parsed.moduleName ||
257+
decodedCommand.templateId.entityName !== parsed.entityName
258+
) {
259+
throw new Error(
260+
`CantonCommand verifyTransaction: templateId mismatch — expected '${parsed.moduleName}:${parsed.entityName}', got '${decodedCommand.templateId.moduleName}:${decodedCommand.templateId.entityName}'`
261+
);
262+
}
263+
264+
// Build the inject-as skip set once for use across the contractId and argument checks
265+
const skipPaths = utils.normalizeInjectAs(userParams.resolveContracts);
266+
267+
// metadata.submitterInfo.actAs must contain exactly the same parties as the user's actAs
268+
const submitterActAs = cantonTx.cantonCommandActAsParties ?? [];
269+
if (!utils.sameElements(submitterActAs, userParams.actAs)) {
270+
throw new Error(
271+
`CantonCommand verifyTransaction: submitterInfo.actAs [${submitterActAs.join(
272+
', '
273+
)}] does not match user actAs [${userParams.actAs.join(', ')}]`
274+
);
275+
}
276+
277+
if (userKind === 'ExerciseCommand') {
278+
const exerciseInner = userInner as CantonExerciseCommand['ExerciseCommand'];
279+
280+
// choice id
281+
if (decodedCommand.choice !== exerciseInner.choice) {
282+
throw new Error(
283+
`CantonCommand verifyTransaction: choice mismatch — expected '${exerciseInner.choice}', got '${
284+
decodedCommand.choice ?? ''
285+
}'`
286+
);
287+
}
288+
289+
// every on-chain actingParty must be in the user's actAs (prevents privilege escalation)
290+
const onChainActors = decodedCommand.actingParties ?? [];
291+
for (const actor of onChainActors) {
292+
if (!userParams.actAs.includes(actor)) {
293+
throw new Error(
294+
`CantonCommand verifyTransaction: unauthorized acting party '${actor}' on root exercise (not in user actAs)`
295+
);
296+
}
297+
}
298+
299+
// contractId — skip when absent/empty or when IMS will inject it via resolveContracts
300+
if (
301+
exerciseInner.contractId !== undefined &&
302+
exerciseInner.contractId !== '' &&
303+
!skipPaths.has('ExerciseCommand.contractId')
304+
) {
305+
if (decodedCommand.contractId !== exerciseInner.contractId) {
306+
throw new Error(
307+
`CantonCommand verifyTransaction: contractId mismatch — expected '${exerciseInner.contractId}', got '${
308+
decodedCommand.contractId ?? ''
309+
}'`
310+
);
311+
}
312+
}
313+
314+
// deep argument compare
315+
const argumentSkipPaths = this.relativeSkipPaths(skipPaths, 'ExerciseCommand.choiceArgument.');
316+
utils.assertDeepCantonMatch(exerciseInner.choiceArgument, decodedCommand.argument, argumentSkipPaths);
317+
} else {
318+
const createInner = userInner as CantonCreateCommand['CreateCommand'];
319+
320+
// deep argument compare
321+
const argumentSkipPaths = this.relativeSkipPaths(skipPaths, 'CreateCommand.createArguments.');
322+
utils.assertDeepCantonMatch(createInner.createArguments, decodedCommand.argument, argumentSkipPaths);
323+
}
324+
325+
return true;
326+
}
327+
328+
private relativeSkipPaths(skipPaths: Set<string>, prefix: string): Set<string> {
329+
const out = new Set<string>();
330+
for (const p of skipPaths) {
331+
if (p.startsWith(prefix)) {
332+
const stripped = p.slice(prefix.length);
333+
if (stripped) out.add(stripped);
334+
}
335+
}
336+
return out;
337+
}
338+
187339
/** @inheritDoc */
188340
async isWalletAddress(params: TssVerifyAddressOptions): Promise<boolean> {
189341
// TODO: refactor this and use the `verifyEddsaMemoBasedWalletAddress` once published from sdk-core
@@ -287,5 +439,8 @@ export class Canton extends BaseCoin {
287439
if (params.unspents) {
288440
intent.unspents = params.unspents;
289441
}
442+
if (params.cantonCommandParams) {
443+
intent.cantonCommandParams = params.cantonCommandParams;
444+
}
290445
}
291446
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import {
2+
InvalidTransactionError,
3+
PublicKey,
4+
TransactionType,
5+
CantonCommand,
6+
CantonCommandResolveContractSpec,
7+
} from '@bitgo/sdk-core';
8+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
9+
import { CANTON_COMMAND_KEYS, CantonCommandRequest, CantonPrepareCommandResponse } from './iface';
10+
import { TransactionBuilder } from './transactionBuilder';
11+
import { Transaction } from './transaction/transaction';
12+
import utils from './utils';
13+
14+
export class CantonCommandBuilder extends TransactionBuilder {
15+
private _commandId: string;
16+
private _actAs: string[] = [];
17+
private _readAs: string[] = [];
18+
private _command: CantonCommand;
19+
private _resolveContracts: CantonCommandResolveContractSpec[] = [];
20+
21+
constructor(_coinConfig: Readonly<CoinConfig>) {
22+
super(_coinConfig);
23+
}
24+
25+
initBuilder(tx: Transaction): void {
26+
super.initBuilder(tx);
27+
this.setTransactionType();
28+
try {
29+
this._commandId = tx.id;
30+
} catch {
31+
// tx.id throws when not set — leave _commandId uninitialized
32+
}
33+
const parties = tx.cantonCommandActAsParties;
34+
if (parties.length > 0) {
35+
this._actAs = parties;
36+
}
37+
}
38+
39+
get transactionType(): TransactionType {
40+
return TransactionType.CantonCommand;
41+
}
42+
43+
setTransactionType(): void {
44+
this.transaction.transactionType = TransactionType.CantonCommand;
45+
}
46+
47+
setTransaction(transaction: CantonPrepareCommandResponse): void {
48+
this.transaction.prepareCommand = transaction;
49+
}
50+
51+
/** @inheritDoc */
52+
addSignature(publicKey: PublicKey, signature: Buffer): void {
53+
if (!this.transaction) {
54+
throw new InvalidTransactionError('transaction is empty!');
55+
}
56+
this._signatures.push({ publicKey, signature });
57+
const pubKeyBase64 = utils.getBase64FromHex(publicKey.pub);
58+
this.transaction.signerFingerprint = utils.getAddressFromPublicKey(pubKeyBase64);
59+
this.transaction.signatures = signature.toString('base64');
60+
}
61+
62+
/**
63+
* Sets the unique command id. Also sets the transaction _id.
64+
*
65+
* @param id - A uuid
66+
* @returns The current builder instance for chaining.
67+
*/
68+
commandId(id: string): this {
69+
if (!id || !id.trim()) {
70+
throw new Error('commandId must be a non-empty string');
71+
}
72+
this._commandId = id.trim();
73+
this.transaction.id = id.trim();
74+
return this;
75+
}
76+
77+
/**
78+
* Sets the parties that will act in the DAML submission.
79+
*
80+
* @param parties - Non-empty array of fully-qualified party ids
81+
* @returns The current builder instance for chaining.
82+
*/
83+
actAs(parties: string[]): this {
84+
if (!parties || parties.length === 0) {
85+
throw new Error('actAs must be a non-empty array');
86+
}
87+
if (parties.some((p) => !p || !p.trim())) {
88+
throw new Error('actAs parties must be non-empty strings');
89+
}
90+
this._actAs = parties;
91+
this.transaction.cantonCommandActAs = parties;
92+
return this;
93+
}
94+
95+
/**
96+
* Sets the read-only parties for the DAML submission.
97+
*
98+
* @param parties - Array of fully-qualified party ids
99+
* @returns The current builder instance for chaining.
100+
*/
101+
readAs(parties?: string[] | null): this {
102+
this._readAs = parties ?? [];
103+
return this;
104+
}
105+
106+
/**
107+
* Sets the opaque DAML command object (CreateCommand or ExerciseCommand).
108+
*
109+
* @param command - The raw DAML command as a plain object
110+
* @returns The current builder instance for chaining.
111+
*/
112+
command(command: CantonCommand): this {
113+
if (!command || typeof command !== 'object' || Array.isArray(command)) {
114+
throw new Error('command must be a plain object');
115+
}
116+
this._command = command;
117+
return this;
118+
}
119+
120+
/**
121+
* Sets the list of ACS contract resolution specs that IMS will resolve before prepare.
122+
*
123+
* @param specs - Array of CantonCommandResolveContractSpec
124+
* @returns The current builder instance for chaining.
125+
*/
126+
resolveContracts(specs?: CantonCommandResolveContractSpec[] | null): this {
127+
this._resolveContracts = specs ?? [];
128+
return this;
129+
}
130+
131+
/**
132+
* Builds and returns the CantonCommandRequest from the builder's internal state.
133+
*
134+
* @returns {CantonCommandRequest}
135+
* @throws {Error} If any required field is missing.
136+
*/
137+
toRequestObject(): CantonCommandRequest {
138+
this.validate();
139+
140+
return {
141+
commandId: this._commandId,
142+
actAs: this._actAs,
143+
readAs: this._readAs ?? [],
144+
command: this._command,
145+
resolveContracts: this._resolveContracts ?? [],
146+
};
147+
}
148+
149+
private validate(): void {
150+
if (!this._commandId) throw new Error('commandId is missing');
151+
if (!this._actAs || this._actAs.length === 0) throw new Error('actAs is missing');
152+
if (!this._command) throw new Error('command is missing');
153+
const presentKeys = CANTON_COMMAND_KEYS.filter((key) => key in this._command);
154+
if (presentKeys.length !== 1) {
155+
throw new Error(`command must contain exactly one of: ${CANTON_COMMAND_KEYS.join(', ')}`);
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)