@@ -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,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}
0 commit comments