Skip to content

Commit c78c58e

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

15 files changed

Lines changed: 1960 additions & 14 deletions

File tree

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

Lines changed: 155 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,142 @@ 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+
// Input shape is enforced by mpcUtils at build time; here we resolve which branch is present.
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: command must contain a CreateCommand or ExerciseCommand wrapper`
225+
);
226+
}
227+
if (hasCreate && hasExercise) {
228+
throw new Error(
229+
`CantonCommand verifyTransaction: command must contain exactly one of CreateCommand or ExerciseCommand, not both`
230+
);
231+
}
232+
const userKind: CantonCommandKind = hasCreate ? 'CreateCommand' : 'ExerciseCommand';
233+
if (decodedCommand.kind !== userKind) {
234+
throw new Error(
235+
`CantonCommand verifyTransaction: command kind mismatch — expected ${userKind}, got ${decodedCommand.kind}`
236+
);
237+
}
238+
239+
const userInner =
240+
userKind === 'CreateCommand'
241+
? (userCommand as CantonCreateCommand).CreateCommand
242+
: (userCommand as CantonExerciseCommand).ExerciseCommand;
243+
244+
// templateId (moduleName + entityName; package id is mutable, so ignored)
245+
const parsed = utils.parseCantonTemplateId(userInner.templateId);
246+
if (!parsed) {
247+
throw new Error(
248+
`CantonCommand verifyTransaction: invalid user templateId '${userInner.templateId}' — expected format 'Pkg:Module:Entity'`
249+
);
250+
}
251+
if (
252+
decodedCommand.templateId.moduleName !== parsed.moduleName ||
253+
decodedCommand.templateId.entityName !== parsed.entityName
254+
) {
255+
throw new Error(
256+
`CantonCommand verifyTransaction: templateId mismatch — expected '${parsed.moduleName}:${parsed.entityName}', got '${decodedCommand.templateId.moduleName}:${decodedCommand.templateId.entityName}'`
257+
);
258+
}
259+
260+
// Build the inject-as skip set once for use across the contractId and argument checks
261+
const skipPaths = utils.normalizeInjectAs(userParams.resolveContracts);
262+
263+
// metadata.submitterInfo.actAs must contain exactly the same parties as the user's actAs
264+
if (!Array.isArray(userParams.actAs) || userParams.actAs.length === 0) {
265+
throw new Error(`CantonCommand verifyTransaction: actAs must be a non-empty array of party IDs`);
266+
}
267+
const submitterActAs = cantonTx.cantonCommandActAsParties ?? [];
268+
if (!utils.sameElements(submitterActAs, userParams.actAs)) {
269+
throw new Error(
270+
`CantonCommand verifyTransaction: submitterInfo.actAs [${submitterActAs.join(
271+
', '
272+
)}] does not match user actAs [${userParams.actAs.join(', ')}]`
273+
);
274+
}
275+
276+
if (userKind === 'ExerciseCommand') {
277+
const exerciseInner = userInner as CantonExerciseCommand['ExerciseCommand'];
278+
279+
// choice id
280+
if (decodedCommand.choice !== exerciseInner.choice) {
281+
throw new Error(
282+
`CantonCommand verifyTransaction: choice mismatch — expected '${exerciseInner.choice}', got '${
283+
decodedCommand.choice ?? ''
284+
}'`
285+
);
286+
}
287+
288+
// every on-chain actingParty must be in the user's actAs (prevents privilege escalation)
289+
const onChainActors = decodedCommand.actingParties ?? [];
290+
for (const actor of onChainActors) {
291+
if (!userParams.actAs.includes(actor)) {
292+
throw new Error(
293+
`CantonCommand verifyTransaction: unauthorized acting party '${actor}' on root exercise (not in user actAs)`
294+
);
295+
}
296+
}
297+
298+
// contractId — skip when absent/empty or when IMS will inject it via resolveContracts
299+
if (
300+
exerciseInner.contractId !== undefined &&
301+
exerciseInner.contractId !== '' &&
302+
!skipPaths.has('ExerciseCommand.contractId')
303+
) {
304+
if (decodedCommand.contractId !== exerciseInner.contractId) {
305+
throw new Error(
306+
`CantonCommand verifyTransaction: contractId mismatch — expected '${exerciseInner.contractId}', got '${
307+
decodedCommand.contractId ?? ''
308+
}'`
309+
);
310+
}
311+
}
312+
313+
// deep argument compare
314+
const argumentSkipPaths = this.relativeSkipPaths(skipPaths, 'ExerciseCommand.choiceArgument.');
315+
utils.assertDeepCantonMatch(exerciseInner.choiceArgument, decodedCommand.argument, argumentSkipPaths);
316+
} else {
317+
const createInner = userInner as CantonCreateCommand['CreateCommand'];
318+
319+
// deep argument compare
320+
const argumentSkipPaths = this.relativeSkipPaths(skipPaths, 'CreateCommand.createArguments.');
321+
utils.assertDeepCantonMatch(createInner.createArguments, decodedCommand.argument, argumentSkipPaths);
322+
}
323+
324+
return true;
325+
}
326+
327+
private relativeSkipPaths(skipPaths: Set<string>, prefix: string): Set<string> {
328+
const out = new Set<string>();
329+
for (const p of skipPaths) {
330+
if (p.startsWith(prefix)) {
331+
const stripped = p.slice(prefix.length);
332+
if (stripped) out.add(stripped);
333+
}
334+
}
335+
return out;
336+
}
337+
187338
/** @inheritDoc */
188339
async isWalletAddress(params: TssVerifyAddressOptions): Promise<boolean> {
189340
// TODO: refactor this and use the `verifyEddsaMemoBasedWalletAddress` once published from sdk-core
@@ -287,5 +438,8 @@ export class Canton extends BaseCoin {
287438
if (params.unspents) {
288439
intent.unspents = params.unspents;
289440
}
441+
if (params.cantonCommandParams) {
442+
intent.cantonCommandParams = params.cantonCommandParams;
443+
}
290444
}
291445
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
const normalizedParties = parties.map((p) => p.trim());
88+
if (normalizedParties.some((p) => !p)) {
89+
throw new Error('actAs parties must be non-empty strings');
90+
}
91+
this._actAs = normalizedParties;
92+
this.transaction.cantonCommandActAs = normalizedParties;
93+
return this;
94+
}
95+
96+
/**
97+
* Sets the read-only parties for the DAML submission.
98+
*
99+
* @param parties - Array of fully-qualified party ids
100+
* @returns The current builder instance for chaining.
101+
*/
102+
readAs(parties?: string[] | null): this {
103+
this._readAs = parties ?? [];
104+
return this;
105+
}
106+
107+
/**
108+
* Sets the opaque DAML command object (CreateCommand or ExerciseCommand).
109+
*
110+
* @param command - The raw DAML command as a plain object
111+
* @returns The current builder instance for chaining.
112+
*/
113+
command(command: CantonCommand): this {
114+
if (!command || typeof command !== 'object' || Array.isArray(command)) {
115+
throw new Error('command must be a plain object');
116+
}
117+
this._command = command;
118+
return this;
119+
}
120+
121+
/**
122+
* Sets the list of ACS contract resolution specs that IMS will resolve before prepare.
123+
*
124+
* @param specs - Array of CantonCommandResolveContractSpec
125+
* @returns The current builder instance for chaining.
126+
*/
127+
resolveContracts(specs?: CantonCommandResolveContractSpec[] | null): this {
128+
this._resolveContracts = specs ?? [];
129+
return this;
130+
}
131+
132+
/**
133+
* Builds and returns the CantonCommandRequest from the builder's internal state.
134+
*
135+
* @returns {CantonCommandRequest}
136+
* @throws {Error} If any required field is missing.
137+
*/
138+
toRequestObject(): CantonCommandRequest {
139+
this.validate();
140+
141+
return {
142+
commandId: this._commandId,
143+
actAs: this._actAs,
144+
readAs: this._readAs ?? [],
145+
command: this._command,
146+
resolveContracts: this._resolveContracts ?? [],
147+
};
148+
}
149+
150+
private validate(): void {
151+
if (!this._commandId) throw new Error('commandId is missing');
152+
if (!this._actAs || this._actAs.length === 0) throw new Error('actAs is missing');
153+
if (!this._command) throw new Error('command is missing');
154+
const activeKeys = CANTON_COMMAND_KEYS.filter((key) =>
155+
utils.isPlainObject(this._command[key as keyof CantonCommand])
156+
);
157+
if (activeKeys.length !== 1) {
158+
throw new Error(
159+
`command must contain exactly one of: ${CANTON_COMMAND_KEYS.join(', ')} as a non-null plain object`
160+
);
161+
}
162+
}
163+
}

0 commit comments

Comments
 (0)