Skip to content

Commit 3d0dd5d

Browse files
authored
Merge pull request #8847 from BitGo/SCAAS-9316
feat(sdk-coin-canton): add CantonCommand transaction support
2 parents 26d0805 + b01d400 commit 3d0dd5d

15 files changed

Lines changed: 1980 additions & 14 deletions

File tree

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

Lines changed: 162 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,149 @@ 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 && utils.isPlainObject(userCommand.CreateCommand);
221+
const hasExercise = 'ExerciseCommand' in userCommand && utils.isPlainObject(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+
if (!userInner?.templateId) {
245+
throw new Error(`CantonCommand verifyTransaction: ${userKind}.templateId must be a non-empty string`);
246+
}
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+
if (!Array.isArray(userParams.actAs) || userParams.actAs.length === 0) {
269+
throw new Error(`CantonCommand verifyTransaction: actAs must be a non-empty array of party IDs`);
270+
}
271+
if (!userParams.actAs.every((p) => typeof p === 'string' && p.trim() !== '')) {
272+
throw new Error(`CantonCommand verifyTransaction: all actAs entries must be non-empty strings`);
273+
}
274+
const submitterActAs = cantonTx.cantonCommandActAsParties ?? [];
275+
if (!utils.sameElements(submitterActAs, userParams.actAs)) {
276+
throw new Error(
277+
`CantonCommand verifyTransaction: submitterInfo.actAs [${submitterActAs.join(
278+
', '
279+
)}] does not match user actAs [${userParams.actAs.join(', ')}]`
280+
);
281+
}
282+
283+
if (userKind === 'ExerciseCommand') {
284+
const exerciseInner = userInner as CantonExerciseCommand['ExerciseCommand'];
285+
286+
// choice id
287+
if (decodedCommand.choice !== exerciseInner.choice) {
288+
throw new Error(
289+
`CantonCommand verifyTransaction: choice mismatch — expected '${exerciseInner.choice}', got '${
290+
decodedCommand.choice ?? ''
291+
}'`
292+
);
293+
}
294+
295+
// every on-chain actingParty must be in the user's actAs (prevents privilege escalation)
296+
const onChainActors = decodedCommand.actingParties ?? [];
297+
for (const actor of onChainActors) {
298+
if (!userParams.actAs.includes(actor)) {
299+
throw new Error(
300+
`CantonCommand verifyTransaction: unauthorized acting party '${actor}' on root exercise (not in user actAs)`
301+
);
302+
}
303+
}
304+
305+
// contractId — skip when absent/empty or when IMS will inject it via resolveContracts
306+
if (
307+
exerciseInner.contractId !== undefined &&
308+
exerciseInner.contractId !== '' &&
309+
!skipPaths.has('ExerciseCommand.contractId')
310+
) {
311+
if (decodedCommand.contractId !== exerciseInner.contractId) {
312+
throw new Error(
313+
`CantonCommand verifyTransaction: contractId mismatch — expected '${exerciseInner.contractId}', got '${
314+
decodedCommand.contractId ?? ''
315+
}'`
316+
);
317+
}
318+
}
319+
320+
// deep argument compare
321+
const argumentSkipPaths = this.relativeSkipPaths(skipPaths, 'ExerciseCommand.choiceArgument.');
322+
utils.assertDeepCantonMatch(exerciseInner.choiceArgument, decodedCommand.argument, argumentSkipPaths);
323+
} else {
324+
const createInner = userInner as CantonCreateCommand['CreateCommand'];
325+
326+
// deep argument compare
327+
const argumentSkipPaths = this.relativeSkipPaths(skipPaths, 'CreateCommand.createArguments.');
328+
utils.assertDeepCantonMatch(createInner.createArguments, decodedCommand.argument, argumentSkipPaths);
329+
}
330+
331+
return true;
332+
}
333+
334+
private relativeSkipPaths(skipPaths: Set<string>, prefix: string): Set<string> {
335+
const out = new Set<string>();
336+
for (const p of skipPaths) {
337+
if (p.startsWith(prefix)) {
338+
const stripped = p.slice(prefix.length);
339+
if (stripped) out.add(stripped);
340+
}
341+
}
342+
return out;
343+
}
344+
187345
/** @inheritDoc */
188346
async isWalletAddress(params: TssVerifyAddressOptions): Promise<boolean> {
189347
// TODO: refactor this and use the `verifyEddsaMemoBasedWalletAddress` once published from sdk-core
@@ -287,5 +445,8 @@ export class Canton extends BaseCoin {
287445
if (params.unspents) {
288446
intent.unspents = params.unspents;
289447
}
448+
if (params.cantonCommandParams) {
449+
intent.cantonCommandParams = params.cantonCommandParams;
450+
}
290451
}
291452
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
if (parties && parties.length > 0) {
104+
const normalized = parties.map((p) => p.trim());
105+
if (normalized.some((p) => !p)) {
106+
throw new Error('readAs parties must be non-empty strings');
107+
}
108+
this._readAs = normalized;
109+
} else {
110+
this._readAs = [];
111+
}
112+
return this;
113+
}
114+
115+
/**
116+
* Sets the opaque DAML command object (CreateCommand or ExerciseCommand).
117+
*
118+
* @param command - The raw DAML command as a plain object
119+
* @returns The current builder instance for chaining.
120+
*/
121+
command(command: CantonCommand): this {
122+
if (!command || typeof command !== 'object' || Array.isArray(command)) {
123+
throw new Error('command must be a plain object');
124+
}
125+
this._command = command;
126+
return this;
127+
}
128+
129+
/**
130+
* Sets the list of ACS contract resolution specs that IMS will resolve before prepare.
131+
*
132+
* @param specs - Array of CantonCommandResolveContractSpec
133+
* @returns The current builder instance for chaining.
134+
*/
135+
resolveContracts(specs?: CantonCommandResolveContractSpec[] | null): this {
136+
this._resolveContracts = specs ?? [];
137+
return this;
138+
}
139+
140+
/**
141+
* Builds and returns the CantonCommandRequest from the builder's internal state.
142+
*
143+
* @returns {CantonCommandRequest}
144+
* @throws {Error} If any required field is missing.
145+
*/
146+
toRequestObject(): CantonCommandRequest {
147+
this.validate();
148+
149+
return {
150+
commandId: this._commandId,
151+
actAs: this._actAs,
152+
readAs: this._readAs ?? [],
153+
command: this._command,
154+
resolveContracts: this._resolveContracts ?? [],
155+
};
156+
}
157+
158+
private validate(): void {
159+
if (!this._commandId) throw new Error('commandId is missing');
160+
if (!this._actAs || this._actAs.length === 0) throw new Error('actAs is missing');
161+
if (!this._command) throw new Error('command is missing');
162+
const activeKeys = CANTON_COMMAND_KEYS.filter((key) =>
163+
utils.isPlainObject(this._command[key as keyof CantonCommand])
164+
);
165+
if (activeKeys.length !== 1) {
166+
throw new Error(
167+
`command must contain exactly one of: ${CANTON_COMMAND_KEYS.join(', ')} as a non-null plain object`
168+
);
169+
}
170+
}
171+
}

0 commit comments

Comments
 (0)