Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions modules/sdk-coin-canton/src/canton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -47,6 +47,7 @@ export interface ExplainTransactionOptions {
export interface CantonTransactionParams extends TransactionParams {
cantonCommandParams?: CantonCommandParams;
broadridgeCommandParams?: BroadridgeCommandParams;
executorPartyId?: string;
}

export class Canton extends BaseCoin {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
41 changes: 41 additions & 0 deletions modules/sdk-coin-canton/test/unit/canton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CantonAllocationRejectPrepareResponse,
CantonExerciseCommandPrepareResponse,
CantonCreateCommandPrepareResponse,
CantonStandingDelegationPrepareResponse,
CantonTokenPreApprovalPrepareResponse,
CancelParty,
OneStepEnablement,
Expand Down Expand Up @@ -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 () {
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/baseCoin/iBaseCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions modules/sdk-core/src/bitgo/utils/mpcUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down Expand Up @@ -222,6 +229,7 @@ export abstract class MpcUtils {
'bridgeFunds',
'cantonCommand',
'cantonEndInvestorOnboardingOffer',
'cantonBroadridgeStandingDelegation',
'cantonEndInvestorOnboardingAccept',
'cantonEndInvestorOnboardingReject',
'cantonParticipantOnboardingRequest',
Expand Down
4 changes: 4 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/baseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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). */
Expand Down
7 changes: 7 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const NO_RECIPIENT_TX_TYPES = new Set([
'allocationAllocate',
'allocationOfferWithdrawn',
'cantonCommand',
'cantonBroadridgeStandingDelegation',
'cantonEndInvestorOnboardingOffer',
'cantonEndInvestorOnboardingAccept',
'cantonEndInvestorOnboardingReject',
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
17 changes: 17 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
69 changes: 69 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/mpcUtils.canton.ts
Original file line number Diff line number Diff line change
@@ -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<KeychainsTriplet> {
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/
);
});
});
15 changes: 14 additions & 1 deletion modules/sdk-core/test/unit/bitgo/utils/tss/recipientUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ describe('recipientUtils', function () {
'allocationAllocate',
'allocationOfferWithdrawn',
'cantonCommand',
'cantonBroadridgeStandingDelegation',
'cantonEndInvestorOnboardingOffer',
'cantonEndInvestorOnboardingAccept',
'cantonEndInvestorOnboardingReject',
'cantonParticipantOnboardingRequest',

'pledge',
// Avalanche / Flare cross-chain atomic imports
'import',
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -188,6 +200,7 @@ describe('recipientUtils', function () {
'cantonEndInvestorOnboardingAccept',
'cantonEndInvestorOnboardingReject',
'cantonParticipantOnboardingRequest',
'cantonBroadridgeStandingDelegation',
];

for (const intentType of cantonIntentTypes) {
Expand Down
Loading