@@ -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';
2631import { BaseCoin as StaticsBaseCoin , coins } from '@bitgo/statics' ;
2732import { TransactionBuilderFactory } from './lib' ;
2833import { 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' ;
3036import utils from './lib/utils' ;
3137
3238export 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+
4050export 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}
0 commit comments