From e00a001260ddd918ac45f626b72909aae2abd713 Mon Sep 17 00:00:00 2001 From: Ravi Hegde Date: Mon, 26 Oct 2026 15:37:57 +0530 Subject: [PATCH] feat(sdk-coin-canton): support Broadridge standing delegation intent Ticket: CHALO-1960 --- modules/sdk-coin-canton/src/canton.ts | 52 +++++++++++++- modules/sdk-coin-canton/test/unit/canton.ts | 41 +++++++++++ .../sdk-core/src/bitgo/baseCoin/iBaseCoin.ts | 2 + modules/sdk-core/src/bitgo/utils/mpcUtils.ts | 8 +++ .../sdk-core/src/bitgo/utils/tss/baseTypes.ts | 4 ++ .../src/bitgo/utils/tss/recipientUtils.ts | 7 ++ modules/sdk-core/src/bitgo/wallet/iWallet.ts | 2 + modules/sdk-core/src/bitgo/wallet/wallet.ts | 17 +++++ .../test/unit/bitgo/utils/mpcUtils.canton.ts | 69 +++++++++++++++++++ .../unit/bitgo/utils/tss/recipientUtils.ts | 15 +++- 10 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/utils/mpcUtils.canton.ts diff --git a/modules/sdk-coin-canton/src/canton.ts b/modules/sdk-coin-canton/src/canton.ts index 64187e4cea9..df08b33e82f 100644 --- a/modules/sdk-coin-canton/src/canton.ts +++ b/modules/sdk-coin-canton/src/canton.ts @@ -32,7 +32,7 @@ import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { TransactionBuilderFactory } from './lib'; import { KeyPair as CantonKeyPair } from './lib/keyPair'; import { BroadridgeCommandParams, CantonCommandKind, TxData } from './lib/iface'; -import { broadridgeDescriptorFromCommand } from './lib/broadridge'; +import { broadridgeDescriptorFromCommand, parseBroadridgeSettlementAuthorization } from './lib/broadridge'; import { Transaction } from './lib/transaction/transaction'; import utils from './lib/utils'; @@ -47,6 +47,7 @@ export interface ExplainTransactionOptions { export interface CantonTransactionParams extends TransactionParams { cantonCommandParams?: CantonCommandParams; broadridgeCommandParams?: BroadridgeCommandParams; + executorPartyId?: string; } export class Canton extends BaseCoin { @@ -129,6 +130,13 @@ export class Canton extends BaseCoin { if (broadridgeParams) { return this.verifyBroadridgeCommandTransaction(transaction, broadridgeParams); } + if (txParams.type === 'cantonBroadridgeStandingDelegation') { + const executorPartyId = (txParams as CantonTransactionParams).executorPartyId; + if (!executorPartyId) { + throw new Error('cantonBroadridgeStandingDelegation verifyTransaction: missing executorPartyId'); + } + return this.verifyBroadridgeStandingDelegationTransaction(transaction, executorPartyId); + } switch (transaction.type) { case TransactionType.WalletInitialization: case TransactionType.TransferAccept: @@ -243,6 +251,40 @@ export class Canton extends BaseCoin { } } } + private verifyBroadridgeStandingDelegationTransaction( + transaction: BaseTransaction, + executorPartyId: string + ): boolean { + const rawPrepared = (transaction as Transaction).prepareCommand?.preparedTransaction; + if (!rawPrepared) { + throw new Error('Broadridge standing delegation verifyTransaction: missing preparedTransaction protobuf'); + } + const actual = utils.extractCantonCommandInfo(rawPrepared); + if (actual.kind !== 'CreateCommand' || actual.choice !== undefined) { + throw new Error( + 'Broadridge standing delegation verifyTransaction: expected a StandingDelegationImpl_v1 CreateCommand' + ); + } + const descriptor = broadridgeDescriptorFromCommand(actual); + if ( + !descriptor || + descriptor.standard !== 'CIP-56' || + descriptor.entityName !== 'StandingDelegationImpl_v1' || + descriptor.role !== 'client' + ) { + throw new Error('Broadridge standing delegation verifyTransaction: unsupported prepared command'); + } + const parsed = parseBroadridgeSettlementAuthorization(actual); + if (parsed?.executor !== executorPartyId) { + throw new Error( + `Broadridge standing delegation executor mismatch: expected '${executorPartyId}', got '${ + parsed?.executor ?? '' + }'` + ); + } + return true; + } + private verifyBroadridgeCommandTransaction(transaction: BaseTransaction, params: BroadridgeCommandParams): boolean { const rawPrepared = (transaction as Transaction).prepareCommand?.preparedTransaction; if (!rawPrepared) { @@ -527,7 +569,10 @@ export class Canton extends BaseCoin { } /** @inheritDoc */ - setCoinSpecificFieldsInIntent(intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions): void { + setCoinSpecificFieldsInIntent( + intent: PopulatedIntent, + params: PrebuildTransactionWithIntentOptions & { executorPartyId?: string } + ): void { if (params.txRequestId) { intent.txRequestId = params.txRequestId; } @@ -540,6 +585,9 @@ export class Canton extends BaseCoin { if (params.cantonCommandParams) { intent.cantonCommandParams = params.cantonCommandParams; } + if (params.executorPartyId) { + (intent as PopulatedIntent & { executorPartyId?: string }).executorPartyId = params.executorPartyId; + } if (params.endInvestorPartyId) { intent.endInvestorPartyId = params.endInvestorPartyId; } diff --git a/modules/sdk-coin-canton/test/unit/canton.ts b/modules/sdk-coin-canton/test/unit/canton.ts index 5d59fc950c5..b28fdb34a46 100644 --- a/modules/sdk-coin-canton/test/unit/canton.ts +++ b/modules/sdk-coin-canton/test/unit/canton.ts @@ -11,6 +11,7 @@ import { CantonAllocationRejectPrepareResponse, CantonExerciseCommandPrepareResponse, CantonCreateCommandPrepareResponse, + CantonStandingDelegationPrepareResponse, CantonTokenPreApprovalPrepareResponse, CancelParty, OneStepEnablement, @@ -424,6 +425,46 @@ describe('Canton verifyTransaction - AllocationReject:', function () { result.should.equal(true); }); }); +describe('Canton verifyTransaction - Broadridge standing delegation:', function () { + let bitgo: TestBitGoAPI; + let basecoin: Canton; + const executorPartyId = 'btgo-operator::1220eb693ade486ff8a112f999e6392fc0427bc14da42ee20e51998575a53efef0cd'; + + before(function () { + bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' }); + bitgo.safeRegister('canton', Canton.createInstance); + bitgo.safeRegister('tcanton', Tcanton.createInstance); + bitgo.initializeTestVars(); + basecoin = bitgo.coin('tcanton') as Canton; + }); + + it('verifies the prepared standing delegation executor', async function () { + const txHex = buildRawTx('CantonCommand', CantonStandingDelegationPrepareResponse, 'standing-delegation-001'); + const result = await basecoin.verifyTransaction({ + txPrebuild: { txHex }, + txParams: { + type: 'cantonBroadridgeStandingDelegation', + executorPartyId, + } as CantonTransactionParams, + wallet: {} as IWallet, + }); + result.should.equal(true); + }); + + it('rejects a prepared standing delegation for another executor', async function () { + const txHex = buildRawTx('CantonCommand', CantonStandingDelegationPrepareResponse, 'standing-delegation-002'); + await basecoin + .verifyTransaction({ + txPrebuild: { txHex }, + txParams: { + type: 'cantonBroadridgeStandingDelegation', + executorPartyId: 'other-executor::1220bbbb', + } as CantonTransactionParams, + wallet: {} as IWallet, + }) + .should.be.rejectedWith(/executor mismatch/); + }); +}); describe('Canton explainTransaction - AllocationReject:', function () { it('should return an empty explanation (no funds move on an AllocationRequest reject)', function () { diff --git a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts index 4f7f4e13666..cb4ec7f61c7 100644 --- a/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts +++ b/modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts @@ -290,6 +290,8 @@ export interface TransactionParams { stakingRequestId?: string; /** ERC-7984 shield intent (`type: 'wrapApprove' | 'wrap'`): wrapper coin name and exact amount. */ wrapParams?: { tokenName: string; amount: string }; + /** External settlement executor party ID for Canton standing delegation intents. */ + executorPartyId?: string; } export interface AddressVerificationData { diff --git a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts index c399e842690..c5e68fffc06 100644 --- a/modules/sdk-core/src/bitgo/utils/mpcUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/mpcUtils.ts @@ -167,6 +167,13 @@ export abstract class MpcUtils { assert(params.registrar, `'registrar' is required parameter for ${params.intentType} intent`); assert(params.clientOnboarder, `'clientOnboarder' is required parameter for ${params.intentType} intent`); } + if (params.intentType === 'cantonBroadridgeStandingDelegation') { + assert( + baseCoin.getFamily() === 'canton', + `'cantonBroadridgeStandingDelegation' intent is only supported for the canton coin family` + ); + assert(params.executorPartyId, `'executorPartyId' is required parameter for ${params.intentType} intent`); + } if ( ['transferOfferWithdrawn', 'allocationOfferWithdrawn'].includes(params.intentType) && @@ -222,6 +229,7 @@ export abstract class MpcUtils { 'bridgeFunds', 'cantonCommand', 'cantonEndInvestorOnboardingOffer', + 'cantonBroadridgeStandingDelegation', 'cantonEndInvestorOnboardingAccept', 'cantonEndInvestorOnboardingReject', 'cantonParticipantOnboardingRequest', diff --git a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts index 2c0007d78e2..b8b9e36bd21 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts @@ -412,6 +412,8 @@ export interface PrebuildTransactionWithIntentOptions extends IntentOptionsBase feeToken?: string; /** Canton-specific params for the cantonCommand intent. */ cantonCommandParams?: CantonCommandParams; + /** External settlement executor party ID (cantonBroadridgeStandingDelegation intent). */ + executorPartyId?: string; /** DeFi vault intent fields for defi-* and wrap-native / unwrap-native intents. */ defiParams?: DefiIntentParams; /** ERC-7984 wrap / wrapApprove fields flattened onto the WP intent. */ @@ -530,6 +532,8 @@ export interface PopulatedIntent extends PopulatedIntentBase, DefiIntentFields { amount?: { value: string; symbol: string } | string; /** Canton-specific params serialized into the cantonCommand intent payload. */ cantonCommandParams?: CantonCommandParams; + /** External settlement executor party ID (cantonBroadridgeStandingDelegation intent). */ + executorPartyId?: string; /** Canton party ID of the end investor to onboard (cantonEndInvestorOnboardingOffer intent). */ endInvestorPartyId?: string; /** Reason for rejecting the onboarding offer (cantonEndInvestorOnboardingReject intent). */ diff --git a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts index 275bff0d15c..f33d44b9b05 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts @@ -83,6 +83,7 @@ export const NO_RECIPIENT_TX_TYPES = new Set([ 'allocationAllocate', 'allocationOfferWithdrawn', 'cantonCommand', + 'cantonBroadridgeStandingDelegation', 'cantonEndInvestorOnboardingOffer', 'cantonEndInvestorOnboardingAccept', 'cantonEndInvestorOnboardingReject', @@ -160,6 +161,12 @@ export function resolveEffectiveTxParams( if (!effectiveTxParams.type && txType) { effectiveTxParams.type = txType; } + if ( + !effectiveTxParams.executorPartyId && + typeof (txRequest.intent as PopulatedIntent)?.executorPartyId === 'string' + ) { + effectiveTxParams.executorPartyId = (txRequest.intent as PopulatedIntent).executorPartyId; + } // Propagate stakingRequestId from intent into effectiveTxParams so verifyTssTransaction // overrides can bypass the no-recipient guard without needing access to txRequest directly. diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index de5accf7614..671b8e2302f 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -349,6 +349,8 @@ export interface PrebuildTransactionOptions { * Parameters for executing DAML commands on Canton. */ cantonCommandParams?: CantonCommandParams; + /** External settlement executor party ID (cantonBroadridgeStandingDelegation intent). */ + executorPartyId?: string; /** Canton party ID of the end investor to onboard (cantonEndInvestorOnboardingOffer intent). */ endInvestorPartyId?: string; /** Reason for rejecting the onboarding offer (cantonEndInvestorOnboardingReject intent). */ diff --git a/modules/sdk-core/src/bitgo/wallet/wallet.ts b/modules/sdk-core/src/bitgo/wallet/wallet.ts index d241bc57546..97306290fdd 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallet.ts @@ -4844,6 +4844,23 @@ export class Wallet implements IWallet { ); break; } + case 'cantonBroadridgeStandingDelegation': { + if (!params.executorPartyId) { + throw new Error('executorPartyId is required for cantonBroadridgeStandingDelegation intent'); + } + txRequest = await this.tssUtils!.prebuildTxWithIntent( + { + reqId, + intentType: 'cantonBroadridgeStandingDelegation', + executorPartyId: params.executorPartyId, + comment: params.comment, + sequenceId: params.sequenceId, + }, + apiVersion, + params.preview + ); + break; + } case 'cantonCommand': { if (!params.cantonCommandParams) { throw new Error('cantonCommandParams is required for cantonCommand intent'); diff --git a/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.canton.ts b/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.canton.ts new file mode 100644 index 00000000000..a91f0e4f5ea --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/mpcUtils.canton.ts @@ -0,0 +1,69 @@ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { IBaseCoin, KeychainsTriplet } from '../../../../src/bitgo/baseCoin'; +import { BitGoBase } from '../../../../src/bitgo/bitgoBase'; +import { RequestTracer } from '../../../../src'; +import { MpcUtils } from '../../../../src/bitgo/utils/mpcUtils'; +import { PopulatedIntent, PrebuildTransactionWithIntentOptions } from '../../../../src/bitgo/utils/tss/baseTypes'; + +class TestMpcUtils extends MpcUtils { + createKeychains(): Promise { + return Promise.reject(new Error('unused')); + } +} + +describe('populateIntent cantonBroadridgeStandingDelegation', function () { + const reqId = new RequestTracer(); + let mpcUtils: TestMpcUtils; + let coin: IBaseCoin; + + beforeEach(function () { + const mockBitgo = { getEnv: sinon.stub().returns('test') } as unknown as BitGoBase; + coin = { + getChain: () => 'tcanton', + getFamily: () => 'canton', + isEVM: () => false, + supportsTss: () => true, + setCoinSpecificFieldsInIntent: (intent: PopulatedIntent, params: PrebuildTransactionWithIntentOptions) => { + intent.executorPartyId = params.executorPartyId; + }, + } as unknown as IBaseCoin; + mpcUtils = new TestMpcUtils(mockBitgo, coin); + }); + + it('populates the executor party without recipients', function () { + const intent = mpcUtils.populateIntent(coin, { + reqId, + intentType: 'cantonBroadridgeStandingDelegation', + executorPartyId: 'executor::1220bbbb', + }); + + assert.strictEqual(intent.intentType, 'cantonBroadridgeStandingDelegation'); + assert.strictEqual(intent.executorPartyId, 'executor::1220bbbb'); + assert.strictEqual(intent.recipients, undefined); + }); + + it('requires executorPartyId', function () { + assert.throws( + () => + mpcUtils.populateIntent(coin, { + reqId, + intentType: 'cantonBroadridgeStandingDelegation', + }), + /executorPartyId.*required/ + ); + }); + + it('rejects the intent for non-Canton coins', function () { + const nonCantonCoin = { ...coin, getFamily: () => 'eth' } as unknown as IBaseCoin; + assert.throws( + () => + mpcUtils.populateIntent(nonCantonCoin, { + reqId, + intentType: 'cantonBroadridgeStandingDelegation', + executorPartyId: 'executor::1220bbbb', + }), + /only supported for the canton coin family/ + ); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts index 6a9a4da0e94..bb6ffbf5680 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts @@ -63,11 +63,11 @@ describe('recipientUtils', function () { 'allocationAllocate', 'allocationOfferWithdrawn', 'cantonCommand', + 'cantonBroadridgeStandingDelegation', 'cantonEndInvestorOnboardingOffer', 'cantonEndInvestorOnboardingAccept', 'cantonEndInvestorOnboardingReject', 'cantonParticipantOnboardingRequest', - 'pledge', // Avalanche / Flare cross-chain atomic imports 'import', @@ -114,6 +114,18 @@ describe('recipientUtils', function () { const result = resolveEffectiveTxParams(txRequest, {}); assert.strictEqual(result.type, 'consolidate'); }); + it('propagates the executor party from a Canton standing delegation intent', function () { + const txRequest = makeTxRequest({ + intent: { + intentType: 'cantonBroadridgeStandingDelegation', + executorPartyId: 'executor::1220bbbb', + } as PopulatedIntent, + }); + const result = resolveEffectiveTxParams(txRequest, undefined); + assert.strictEqual(result.type, 'cantonBroadridgeStandingDelegation'); + assert.strictEqual(result.executorPartyId, 'executor::1220bbbb'); + assert.strictEqual(result.recipients, undefined); + }); describe('ERC-7984 shield wrapParams propagation', function () { const tokenName = 'hteth:cusdt'; @@ -188,6 +200,7 @@ describe('recipientUtils', function () { 'cantonEndInvestorOnboardingAccept', 'cantonEndInvestorOnboardingReject', 'cantonParticipantOnboardingRequest', + 'cantonBroadridgeStandingDelegation', ]; for (const intentType of cantonIntentTypes) {