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