diff --git a/modules/sdk-coin-canton/src/canton.ts b/modules/sdk-coin-canton/src/canton.ts index e37fc2a83bd..64187e4cea9 100644 --- a/modules/sdk-coin-canton/src/canton.ts +++ b/modules/sdk-coin-canton/src/canton.ts @@ -31,7 +31,8 @@ import { auditEddsaPrivateKey } from '@bitgo/sdk-lib-mpc'; import { BaseCoin as StaticsBaseCoin, coins } from '@bitgo/statics'; import { TransactionBuilderFactory } from './lib'; import { KeyPair as CantonKeyPair } from './lib/keyPair'; -import { CantonCommandKind, TxData } from './lib/iface'; +import { BroadridgeCommandParams, CantonCommandKind, TxData } from './lib/iface'; +import { broadridgeDescriptorFromCommand } from './lib/broadridge'; import { Transaction } from './lib/transaction/transaction'; import utils from './lib/utils'; @@ -45,6 +46,7 @@ export interface ExplainTransactionOptions { export interface CantonTransactionParams extends TransactionParams { cantonCommandParams?: CantonCommandParams; + broadridgeCommandParams?: BroadridgeCommandParams; } export class Canton extends BaseCoin { @@ -123,6 +125,10 @@ export class Canton extends BaseCoin { const txBuilder = new TransactionBuilderFactory(coinConfig).from(rawTx); const transaction = txBuilder.transaction; const explainedTx = transaction.explainTransaction(); + const broadridgeParams = (txParams as CantonTransactionParams).broadridgeCommandParams; + if (broadridgeParams) { + return this.verifyBroadridgeCommandTransaction(transaction, broadridgeParams); + } switch (transaction.type) { case TransactionType.WalletInitialization: case TransactionType.TransferAccept: @@ -237,6 +243,43 @@ export class Canton extends BaseCoin { } } } + private verifyBroadridgeCommandTransaction(transaction: BaseTransaction, params: BroadridgeCommandParams): boolean { + const rawPrepared = (transaction as Transaction).prepareCommand?.preparedTransaction; + if (!rawPrepared) { + throw new Error('Broadridge verifyTransaction: missing preparedTransaction protobuf'); + } + const actual = utils.extractCantonCommandInfo(rawPrepared); + const actualDescriptor = broadridgeDescriptorFromCommand(actual); + if (!actualDescriptor) { + throw new Error('Broadridge verifyTransaction: prepared command is not a supported Broadridge template'); + } + const expected = params.descriptor; + const expectedTemplate = utils.parseCantonTemplateId(expected.templateId); + if (!expectedTemplate || expected.protocol !== 'broadridge') { + throw new Error('Broadridge verifyTransaction: invalid protocol descriptor'); + } + if ( + expected.standard !== actualDescriptor.standard || + expectedTemplate.pkg !== actualDescriptor.packageId || + expectedTemplate.moduleName !== actualDescriptor.moduleName || + expectedTemplate.entityName !== actualDescriptor.entityName || + expected.choice !== actualDescriptor.choice || + expected.role !== actualDescriptor.role + ) { + throw new Error('Broadridge verifyTransaction: template, package, protocol, choice, or role mismatch'); + } + if (!utils.sameElements(expected.actors, actualDescriptor.actors)) { + throw new Error('Broadridge verifyTransaction: actor set mismatch'); + } + const expectedContractId = expected.contractId ?? expected.requestCid ?? expected.agreementCid; + if (expectedContractId !== undefined && expectedContractId !== actual.contractId) { + throw new Error('Broadridge verifyTransaction: contract/request/agreement CID mismatch'); + } + if (params.argument !== undefined) { + utils.assertDeepCantonMatch(params.argument, actual.argument, new Set()); + } + return true; + } private verifyCantonCommandTransaction( transaction: BaseTransaction, diff --git a/modules/sdk-coin-canton/src/lib/allocationRequestBuilder.ts b/modules/sdk-coin-canton/src/lib/allocationRequestBuilder.ts index 313a31be1e1..3716a664a26 100644 --- a/modules/sdk-coin-canton/src/lib/allocationRequestBuilder.ts +++ b/modules/sdk-coin-canton/src/lib/allocationRequestBuilder.ts @@ -6,12 +6,13 @@ import { Transaction } from './transaction/transaction'; import { normalizeCantonAmount } from './amount'; /** - * Builder for an AllocationRequest txRequest — an internal, non-signable transaction - * that surfaces the DvP trade leg details to the allocating party. The party reviews - * this request and then submits a separate AllocationAllocate to lock their asset. + * Builder for an AllocationRequest txRequest — an internal request that surfaces the DvP trade + * leg details to the allocating party. The party reviews this request and then submits a + * separate AllocationAllocate to lock their asset. * - * setTransaction and addSignature are intentionally not implemented because this - * transaction type is never signed or broadcast directly. + * Allocation requests are not normally signed or broadcast as standalone transactions, but the + * builder still supports the standard prepared-transaction and signature hooks for transaction + * builder compatibility. */ export class AllocationRequestBuilder extends TransactionBuilder { private _updateId: string; @@ -49,12 +50,15 @@ export class AllocationRequestBuilder extends TransactionBuilder { } setTransaction(transaction: CantonPrepareCommandResponse): void { - throw new Error('Not implemented!'); + this.transaction.prepareCommand = transaction; } /** @inheritDoc */ addSignature(publicKey: PublicKey, signature: Buffer): void { - throw new Error('Not implemented!'); + if (!this.transaction) { + throw new Error('transaction is empty!'); + } + this._signatures.push({ publicKey, signature }); } /** @@ -294,13 +298,8 @@ export class AllocationRequestBuilder extends TransactionBuilder { if (!this._receiverPartyId) throw new Error('receiverPartyId is missing'); if (this._amount === undefined || this._amount === null) throw new Error('amount is missing'); if (!this._token) throw new Error('token is missing'); - if (this._legType !== 'fee') { - // Trade legs (explicit or legacy) always carry the receive side of the settlement; - // fee legs are payment-only — the fee payer receives nothing — so the receive fields are omitted. - if (!this._receiveToken) throw new Error('receiveToken is missing'); - if (this._receiveAmount === undefined || this._receiveAmount === null) - throw new Error('receiveAmount is missing'); - } + // CIP-56 payment-only requests legitimately have no receive leg. Keep receive + // fields optional rather than inventing a counter-leg for the wallet. if (!this._allocateBefore) throw new Error('allocateBefore is missing'); if (!this._settleBefore) throw new Error('settleBefore is missing'); } diff --git a/modules/sdk-coin-canton/src/lib/broadridge.ts b/modules/sdk-coin-canton/src/lib/broadridge.ts new file mode 100644 index 00000000000..ac0286c75f2 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/broadridge.ts @@ -0,0 +1,321 @@ +import { + BroadridgeAllocationLeg, + BroadridgeAllocationRequestData, + BroadridgeAllocationRequestOutcome, + BroadridgeAllocationSpecification, + BroadridgeProtocolDescriptor, + BroadridgeSettlementAuthorizationData, + BroadridgeSettlementIdentity, + CantonCommandInfo, +} from './iface'; + +const V1_ALLOCATION_MODULE = 'Broadridge.Token.Implementations.AllocationRequestImpl_v1'; +const V2_ALLOCATION_MODULE = 'Broadridge.Token.Implementations.AllocationRequestImpl_v2'; +const SETTLEMENT_MODULE = 'Broadridge.Token.Implementations'; + +function settlementModule(entityName: string): string { + return `${SETTLEMENT_MODULE}.${entityName}`; +} + +export const BROADRIDGE_TEMPLATE_NAMES = { + allocationRequestV1: 'AllocationRequestImpl_v1', + allocationRequestV2: 'AllocationRequestImpl_v2', + standingDelegation: 'StandingDelegationImpl_v1', + settlementProposal: 'SettlementProposalImpl_v1', + settlementAgreement: 'SettlementAgreementImpl_v1', +} as const; + +export const BROADRIDGE_CHOICES = { + requestAccept: 'AllocationRequest_Accept', + requestReject: 'AllocationRequest_Reject', + requestWithdraw: 'AllocationRequest_Withdraw', + archive: 'Archive', + delegationRevoke: 'StandingDelegation_Revoke', + delegationWithdraw: 'StandingDelegation_Withdraw', + delegationPropose: 'StandingDelegation_ProposeSettlement', + delegationAccept: 'StandingDelegation_AcceptSettlement', + proposalAccept: 'SettlementProposal_Accept', + proposalReject: 'SettlementProposal_Reject', + proposalWithdraw: 'SettlementProposal_Withdraw', + agreementSettle: 'SettlementAgreement_Settle', + agreementCancel: 'SettlementAgreement_Cancel', +} as const; + +export type BroadridgeOperation = (typeof BROADRIDGE_CHOICES)[keyof typeof BROADRIDGE_CHOICES] | 'Create'; + +export function isBroadridgeAllocationTemplate(entityName: string): boolean { + return ( + entityName === BROADRIDGE_TEMPLATE_NAMES.allocationRequestV1 || + entityName === BROADRIDGE_TEMPLATE_NAMES.allocationRequestV2 + ); +} + +export function isBroadridgeSettlementTemplate(entityName: string): boolean { + return ( + entityName === BROADRIDGE_TEMPLATE_NAMES.standingDelegation || + entityName === BROADRIDGE_TEMPLATE_NAMES.settlementProposal || + entityName === BROADRIDGE_TEMPLATE_NAMES.settlementAgreement + ); +} + +export function broadridgeDescriptorFromCommand(info: CantonCommandInfo): BroadridgeProtocolDescriptor | undefined { + const { moduleName, entityName, packageId } = info.templateId; + const isV1 = moduleName === V1_ALLOCATION_MODULE && entityName === BROADRIDGE_TEMPLATE_NAMES.allocationRequestV1; + const isV2 = moduleName === V2_ALLOCATION_MODULE && entityName === BROADRIDGE_TEMPLATE_NAMES.allocationRequestV2; + const isSettlement = + isBroadridgeSettlementTemplate(entityName) && + (moduleName === SETTLEMENT_MODULE || moduleName === settlementModule(entityName)); + if (!isV1 && !isV2 && !isSettlement) return undefined; + const choice = info.choice; + const supportedChoices: readonly string[] = + isV1 || isV2 + ? [ + BROADRIDGE_CHOICES.requestAccept, + BROADRIDGE_CHOICES.requestReject, + BROADRIDGE_CHOICES.requestWithdraw, + BROADRIDGE_CHOICES.archive, + ] + : entityName === BROADRIDGE_TEMPLATE_NAMES.standingDelegation + ? [ + BROADRIDGE_CHOICES.delegationRevoke, + BROADRIDGE_CHOICES.delegationWithdraw, + BROADRIDGE_CHOICES.delegationPropose, + BROADRIDGE_CHOICES.delegationAccept, + ] + : entityName === BROADRIDGE_TEMPLATE_NAMES.settlementProposal + ? [BROADRIDGE_CHOICES.proposalAccept, BROADRIDGE_CHOICES.proposalReject, BROADRIDGE_CHOICES.proposalWithdraw] + : [BROADRIDGE_CHOICES.agreementSettle, BROADRIDGE_CHOICES.agreementCancel]; + if (choice && !supportedChoices.includes(choice)) return undefined; + const role: BroadridgeProtocolDescriptor['role'] = + isV1 || isV2 + ? 'request' + : entityName === BROADRIDGE_TEMPLATE_NAMES.standingDelegation + ? !choice || choice === BROADRIDGE_CHOICES.delegationRevoke + ? 'client' + : 'executor' + : entityName === BROADRIDGE_TEMPLATE_NAMES.settlementProposal + ? choice === BROADRIDGE_CHOICES.proposalAccept || choice === BROADRIDGE_CHOICES.proposalReject + ? 'trading-party' + : 'executor' + : 'executor'; + const standard = isV2 ? 'CIP-112' : 'CIP-56'; + return { + protocol: 'broadridge', + standard, + packageId, + moduleName, + entityName, + templateId: `${packageId}:${moduleName}:${entityName}`, + ...(choice ? { choice } : {}), + role, + actors: info.actingParties ?? [], + ...(info.contractId ? { contractId: info.contractId } : {}), + ...(isV1 || isV2 ? { requestCid: info.contractId } : {}), + ...(entityName === BROADRIDGE_TEMPLATE_NAMES.settlementAgreement ? { agreementCid: info.contractId } : {}), + }; +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function optionalString(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + return typeof value === 'string' ? value : String(value); +} + +function party(value: unknown): string | undefined { + return stringValue(value) ?? stringValue(asRecord(value).owner); +} + +function instrument(value: unknown): { instrumentId?: string; instrumentAdmin?: string } { + const record = asRecord(value); + return { + instrumentId: stringValue(record.id), + instrumentAdmin: party(record.admin) ?? party(record.source), + }; +} + +function settlement(value: unknown): BroadridgeSettlementIdentity | undefined { + const record = asRecord(value); + if (Object.keys(record).length === 0) return undefined; + return { + executor: party(record.executor), + executors: Array.isArray(record.executors) + ? record.executors.map(party).filter((v): v is string => !!v) + : undefined, + id: optionalString(record.id ?? record.reference ?? record.settlementId), + cid: optionalString(record.cid ?? record.contractId), + requestedAt: optionalString(record.requestedAt), + allocateBefore: optionalString(record.allocateBefore), + settleBefore: optionalString(record.settleBefore), + settleAt: optionalString(record.settleAt), + meta: record.meta, + }; +} + +function leg(value: unknown, legId?: string, side?: string): BroadridgeAllocationLeg { + const record = asRecord(value); + const other = asRecord(record.otherside ?? record.otherSide); + const instrumentData = instrument(record.instrumentId ?? record.instrument); + const sender = party(record.sender); + const receiver = party(record.receiver); + const otherOwner = party(other.owner ?? other); + return { + ...record, + ...(legId ? { legId } : {}), + ...(side ?? record.side ? { side: side ?? optionalString(record.side) } : {}), + ...(sender ? { senderPartyId: sender } : {}), + ...(receiver ? { receiverPartyId: receiver } : {}), + ...(otherOwner && side === 'SenderSide' ? { receiverPartyId: otherOwner } : {}), + ...(otherOwner && side === 'ReceiverSide' ? { senderPartyId: otherOwner } : {}), + ...(optionalString(record.amount) !== undefined ? { amount: optionalString(record.amount) } : {}), + ...instrumentData, + }; +} + +function v1Legs(value: unknown): BroadridgeAllocationLeg[] { + const map = asRecord(value); + return Object.entries(map).map(([legId, value]) => leg(value, legId)); +} + +function v2Allocation(value: unknown): BroadridgeAllocationSpecification { + const record = asRecord(value); + const authorizer = asRecord(record.authorizer); + const sides = Array.isArray(record.transferLegSides) + ? record.transferLegSides.map((side) => { + const sideRecord = asRecord(side); + return leg(side, optionalString(sideRecord.legId ?? sideRecord.id), optionalString(sideRecord.side)); + }) + : []; + return { + ...record, + ...(stringValue(record.admin) ? { admin: stringValue(record.admin) } : {}), + authorizer: { + ...(party(authorizer.owner) ? { owner: party(authorizer.owner) } : {}), + ...(party(authorizer.provider) ? { provider: party(authorizer.provider) } : {}), + ...(optionalString(authorizer.id) !== undefined ? { id: optionalString(authorizer.id) } : {}), + }, + transferLegSides: sides, + receiverOnly: sides.length > 0 && !sides.some((side) => side.side === 'SenderSide'), + inputHoldingCids: sides.length > 0 && !sides.some((side) => side.side === 'SenderSide') ? [] : undefined, + deadline: optionalString(record.deadline ?? record.settleBefore), + committed: typeof record.committed === 'boolean' ? record.committed : undefined, + nextIterationFunding: record.nextIterationFunding, + meta: record.meta, + }; +} + +export function parseBroadridgeAllocationRequest( + info: CantonCommandInfo, + rawArgument: unknown = info.argument +): BroadridgeAllocationRequestData | undefined { + const descriptor = broadridgeDescriptorFromCommand(info); + if (!descriptor || !isBroadridgeAllocationTemplate(info.templateId.entityName) || info.kind !== 'CreateCommand') + return undefined; + const raw = asRecord(rawArgument); + const isV2 = descriptor.standard === 'CIP-112'; + const settlementData = settlement(raw.settlement); + const result: BroadridgeAllocationRequestData = { + version: isV2 ? 'v2' : 'v1', + templateId: descriptor.templateId, + executor: party(raw.executor), + settlement: settlementData, + ...(isV2 + ? { + allocations: Array.isArray(raw.allocations) ? raw.allocations.map(v2Allocation) : [], + requestedAt: optionalString(raw.requestedAt), + settleAt: optionalString(raw.settleAt), + } + : { transferLegs: v1Legs(raw.transferLegs) }), + meta: raw.meta, + raw, + }; + return result; +} + +function outcomeStatus(choice: string): BroadridgeAllocationRequestOutcome['status'] | undefined { + switch (choice) { + case BROADRIDGE_CHOICES.requestAccept: + return 'accepted'; + case BROADRIDGE_CHOICES.requestReject: + return 'rejected'; + case BROADRIDGE_CHOICES.requestWithdraw: + return 'withdrawn'; + case BROADRIDGE_CHOICES.archive: + return 'archived'; + default: + return undefined; + } +} + +export function parseBroadridgeAllocationOutcome( + info: CantonCommandInfo +): BroadridgeAllocationRequestOutcome | undefined { + const descriptor = broadridgeDescriptorFromCommand(info); + if ( + !descriptor || + !isBroadridgeAllocationTemplate(info.templateId.entityName) || + info.kind !== 'ExerciseCommand' || + !info.choice + ) { + return undefined; + } + const status = outcomeStatus(info.choice); + if (!status) return undefined; + const argument = asRecord(info.argument); + const extraArgs = asRecord(argument.extraArgs); + const meta = extraArgs.meta ?? argument.meta; + return { + choice: info.choice as BroadridgeAllocationRequestOutcome['choice'], + requestCid: info.contractId, + actors: info.actingParties ?? [], + ...(meta !== undefined ? { meta } : {}), + status, + }; +} + +function valueField(record: Record, ...names: string[]): unknown { + for (const name of names) { + if (record[name] !== undefined) return record[name]; + } + return undefined; +} + +export function parseBroadridgeSettlementAuthorization( + info: CantonCommandInfo +): BroadridgeSettlementAuthorizationData | undefined { + const descriptor = broadridgeDescriptorFromCommand(info); + if (!descriptor || !isBroadridgeSettlementTemplate(info.templateId.entityName)) return undefined; + const argument = asRecord(info.argument); + const template = info.templateId.entityName; + const data: BroadridgeSettlementAuthorizationData = { + descriptor, + client: party(valueField(argument, 'client')), + executor: party(valueField(argument, 'executor')), + tradingParties: Array.isArray(argument.tradingParties) + ? argument.tradingParties.map(party).filter((v): v is string => !!v) + : undefined, + approvers: Array.isArray(argument.approvers) + ? argument.approvers.map(party).filter((v): v is string => !!v) + : undefined, + settlement: settlement(argument.settlement), + delegationCid: optionalString(valueField(argument, 'delegationCid', 'delegationContractId')), + proposalCid: optionalString(valueField(argument, 'proposalCid', 'proposalContractId')), + agreementCid: optionalString(valueField(argument, 'agreementCid', 'agreementContractId')), + allocationCids: Array.isArray(argument.allocationCids) + ? argument.allocationCids.map(optionalString).filter((v): v is string => !!v) + : undefined, + meta: valueField(argument, 'meta'), + reason: asRecord(argument.extraArgs).meta, + result: valueField(argument, 'result', 'results'), + }; + if (template === BROADRIDGE_TEMPLATE_NAMES.standingDelegation && !data.client && descriptor.choice === undefined) { + data.client = party(argument.client); + } + return data; +} diff --git a/modules/sdk-coin-canton/src/lib/broadridgeSettlementAuthorizationBuilder.ts b/modules/sdk-coin-canton/src/lib/broadridgeSettlementAuthorizationBuilder.ts new file mode 100644 index 00000000000..156f4f68f84 --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/broadridgeSettlementAuthorizationBuilder.ts @@ -0,0 +1,193 @@ +import { CantonCommand, PublicKey, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { + BroadridgeCommandRequest, + BroadridgeProtocolDescriptor, + BroadridgeSettlementIdentity, + CantonPrepareCommandResponse, +} from './iface'; +import { CantonCommandBuilder } from './cantonCommandBuilder'; +import { BROADRIDGE_CHOICES, BROADRIDGE_TEMPLATE_NAMES, BroadridgeOperation } from './broadridge'; + +export interface BroadridgeSettlementAuthorizationParams { + commandId: string; + templateId: string; + actAs: string[]; + contractId?: string; + choiceArgument?: Record; + settlement?: BroadridgeSettlementIdentity; + requestCid?: string; + agreementCid?: string; + allocationCids?: string[]; +} + +/** Builds only the exact CIP-56 Settlement Authorization commands. */ +export class BroadridgeSettlementAuthorizationBuilder extends CantonCommandBuilder { + private _broadridge?: BroadridgeProtocolDescriptor; + + constructor(coinConfig: Readonly) { + super(coinConfig); + } + + get transactionType(): TransactionType { + return TransactionType.CantonCommand; + } + + setTransactionType(): void { + this.transaction.transactionType = TransactionType.CantonCommand; + } + + setTransaction(transaction: CantonPrepareCommandResponse): void { + this.transaction.prepareCommand = transaction; + } + + addSignature(publicKey: PublicKey, signature: Buffer): void { + super.addSignature(publicKey, signature); + } + + override toRequestObject(): BroadridgeCommandRequest { + const request = super.toRequestObject() as BroadridgeCommandRequest; + if (!this._broadridge) { + throw new Error('Broadridge protocol descriptor is missing'); + } + request.broadridge = this._broadridge; + return request; + } + + createStandingDelegation(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure(params, BROADRIDGE_TEMPLATE_NAMES.standingDelegation, 'Create', 'client'); + } + + revokeStandingDelegation(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.standingDelegation, + BROADRIDGE_CHOICES.delegationRevoke, + 'client' + ); + } + + withdrawStandingDelegation(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.standingDelegation, + BROADRIDGE_CHOICES.delegationWithdraw, + 'executor' + ); + } + + proposeSettlement(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.standingDelegation, + BROADRIDGE_CHOICES.delegationPropose, + 'executor' + ); + } + + acceptSettlement(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.standingDelegation, + BROADRIDGE_CHOICES.delegationAccept, + 'executor' + ); + } + + acceptSettlementProposal(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.settlementProposal, + BROADRIDGE_CHOICES.proposalAccept, + 'trading-party' + ); + } + + rejectSettlementProposal(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.settlementProposal, + BROADRIDGE_CHOICES.proposalReject, + 'trading-party' + ); + } + + withdrawSettlementProposal(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.settlementProposal, + BROADRIDGE_CHOICES.proposalWithdraw, + 'executor' + ); + } + + settleAgreement(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.settlementAgreement, + BROADRIDGE_CHOICES.agreementSettle, + 'executor' + ); + } + + cancelAgreement(params: BroadridgeSettlementAuthorizationParams): this { + return this.configure( + params, + BROADRIDGE_TEMPLATE_NAMES.settlementAgreement, + BROADRIDGE_CHOICES.agreementCancel, + 'executor' + ); + } + + private configure( + params: BroadridgeSettlementAuthorizationParams, + entityName: string, + operation: BroadridgeOperation, + role: BroadridgeProtocolDescriptor['role'] + ): this { + if (!params || !params.templateId || !params.templateId.trim()) { + throw new Error('templateId must be a non-empty string'); + } + const parts = params.templateId.split(':'); + const expectedModule = `Broadridge.Token.Implementations.${entityName}`; + if ( + parts.length !== 3 || + !parts[0] || + (parts[1] !== 'Broadridge.Token.Implementations' && parts[1] !== expectedModule) || + parts[2] !== entityName + ) { + throw new Error(`templateId must identify Broadridge.Token.Implementations:${entityName}`); + } + this.commandId(params.commandId); + this.actAs(params.actAs); + const command: CantonCommand = + operation === 'Create' + ? { CreateCommand: { templateId: params.templateId, createArguments: params.choiceArgument ?? {} } } + : { + ExerciseCommand: { + templateId: params.templateId, + contractId: params.contractId, + choice: operation, + choiceArgument: params.choiceArgument ?? {}, + }, + }; + this.command(command); + this._broadridge = { + protocol: 'broadridge', + standard: 'CIP-56', + packageId: parts[0], + moduleName: parts[1], + entityName: parts[2], + templateId: params.templateId, + ...(operation !== 'Create' ? { choice: operation } : {}), + role, + actors: [...params.actAs], + ...(params.contractId ? { contractId: params.contractId } : {}), + ...(params.requestCid ? { requestCid: params.requestCid } : {}), + ...(params.agreementCid ? { agreementCid: params.agreementCid } : {}), + ...(params.settlement ? { settlement: params.settlement } : {}), + ...(params.allocationCids ? { allocationCids: [...params.allocationCids] } : {}), + }; + return this; + } +} diff --git a/modules/sdk-coin-canton/src/lib/iface.ts b/modules/sdk-coin-canton/src/lib/iface.ts index 4741f699ace..94c123ea01b 100644 --- a/modules/sdk-coin-canton/src/lib/iface.ts +++ b/modules/sdk-coin-canton/src/lib/iface.ts @@ -12,14 +12,17 @@ export interface TransactionExplanation extends BaseTransactionExplanation { inputs?: ITransactionRecipient[]; inputAmount?: string; cantonCommand?: CantonCommandExplain; + broadridge?: BroadridgeProtocolDescriptor; + broadridgeAllocationRequestData?: BroadridgeAllocationRequestData; + broadridgeSettlementAuthorizationData?: BroadridgeSettlementAuthorizationData; } - export interface CantonCommandExplain { kind: CantonCommandKind; templateId: string; actAs: string[]; choice?: string; contractId?: string; + broadridge?: BroadridgeProtocolDescriptor; } /** @@ -34,18 +37,133 @@ export interface TxData { acknowledgeData?: TransferAcknowledge; cosignDelegationProposalData?: CosignDelegationProposal; allocationRequestData?: AllocationRequest; + broadridgeAllocationRequestData?: BroadridgeAllocationRequestData; + broadridgeSettlementAuthorizationData?: BroadridgeSettlementAuthorizationData; endInvestorOnboardingOfferData?: EndInvestorOnboardingOfferData; memoId?: string; token?: string; cantonCommand?: CantonCommandExplain; + broadridge?: BroadridgeProtocolDescriptor; } - export interface PreparedTxnParsedInfo { sender: string; receiver: string; amount: string; memoId?: string; token?: string; + broadridgeAllocationRequestData?: BroadridgeAllocationRequestData; + broadridgeSettlementAuthorizationData?: BroadridgeSettlementAuthorizationData; +} + +export interface BroadridgeSettlementIdentity { + executor?: string; + executors?: string[]; + id?: string; + cid?: string; + requestedAt?: string; + allocateBefore?: string; + settleBefore?: string; + settleAt?: string; + meta?: unknown; +} + +export interface BroadridgeAllocationLeg { + legId?: string; + side?: 'SenderSide' | 'ReceiverSide' | string; + senderPartyId?: string; + receiverPartyId?: string; + amount?: CantonAmount; + instrumentId?: string; + instrumentAdmin?: string; + meta?: unknown; + [key: string]: unknown; +} +export interface BroadridgeAllocationSpecification { + admin?: string; + authorizer?: { + owner?: string; + provider?: string; + id?: string; + [key: string]: unknown; + }; + transferLegSides?: BroadridgeAllocationLeg[]; + receiverOnly?: boolean; + inputHoldingCids?: string[]; + deadline?: string; + committed?: boolean; + nextIterationFunding?: unknown; + meta?: unknown; + [key: string]: unknown; +} + +export interface BroadridgeAllocationRequestData { + version: 'v1' | 'v2'; + templateId: string; + requestCid?: string; + executor?: string; + settlement?: BroadridgeSettlementIdentity; + transferLegs?: BroadridgeAllocationLeg[]; + allocations?: BroadridgeAllocationSpecification[]; + requestedAt?: string; + settleAt?: string; + meta?: unknown; + originalRequestCid?: string; + outcome?: BroadridgeAllocationRequestOutcome; + raw: Record; +} + +export type BroadridgeAllocationRequestOutcome = { + choice: 'AllocationRequest_Accept' | 'AllocationRequest_Reject' | 'AllocationRequest_Withdraw' | 'Archive'; + requestCid?: string; + actors: string[]; + meta?: unknown; + result?: unknown; + status: 'accepted' | 'rejected' | 'withdrawn' | 'archived'; +}; + +export interface BroadridgeProtocolDescriptor { + protocol: 'broadridge'; + standard: 'CIP-56' | 'CIP-112'; + packageId: string; + moduleName: string; + entityName: string; + templateId: string; + choice?: string; + role: 'client' | 'executor' | 'trading-party' | 'agreement' | 'request' | 'allocation'; + actors: string[]; + contractId?: string; + requestCid?: string; + agreementCid?: string; + settlement?: BroadridgeSettlementIdentity; + allocationCids?: string[]; + reason?: unknown; + result?: unknown; +} + +export interface BroadridgeSettlementAuthorizationData { + descriptor: BroadridgeProtocolDescriptor; + client?: string; + executor?: string; + tradingParties?: string[]; + approvers?: string[]; + settlement?: BroadridgeSettlementIdentity; + delegationCid?: string; + proposalCid?: string; + agreementCid?: string; + allocationCids?: string[]; + meta?: unknown; + reason?: unknown; + result?: unknown; +} + +export interface BroadridgeCommandParams { + descriptor: BroadridgeProtocolDescriptor; + /** Optional command argument subset to compare after descriptor checks. */ + argument?: unknown; +} + +export interface BroadridgeCommandRequest extends CantonCommandRequest { + broadridge?: BroadridgeProtocolDescriptor; } export interface WalletInitTxData { @@ -130,9 +248,10 @@ export interface TransactionBroadcastData { cosignDelegationProposalData?: CosignDelegationProposal; allocationRequestData?: AllocationRequest; endInvestorOnboardingOfferData?: EndInvestorOnboardingOfferData; + broadridge?: BroadridgeProtocolDescriptor; prepareCommandResponse?: CantonPrepareCommandResponse; - txType: string; preparedTransaction?: string; + txType: string; partySignatures?: { signatures: PartySignature[]; }; diff --git a/modules/sdk-coin-canton/src/lib/index.ts b/modules/sdk-coin-canton/src/lib/index.ts index 1e26ab61765..65a0df3a862 100644 --- a/modules/sdk-coin-canton/src/lib/index.ts +++ b/modules/sdk-coin-canton/src/lib/index.ts @@ -6,6 +6,7 @@ export { AllocationAllocateBuilder } from './allocationAllocateBuilder'; export { AllocationAllocateWithdrawnBuilder } from './allocationAllocateWithdrawnBuilder'; export { AllocationRejectBuilder } from './allocationRejectBuilder'; export { AllocationRequestBuilder } from './allocationRequestBuilder'; +export { BroadridgeSettlementAuthorizationBuilder } from './broadridgeSettlementAuthorizationBuilder'; export { EndInvestorOnboardingOfferBuilder } from './endInvestorOnboardingOfferBuilder'; export { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder'; export { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder'; @@ -24,5 +25,6 @@ export { WalletInitBuilder } from './walletInitBuilder'; export { WalletInitTransaction } from './walletInitialization/walletInitTransaction'; export { Utils, Interface }; +export * from './broadridge'; export * from './messages'; export * from './clearSigning'; diff --git a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts index 387aafdd210..7ea5ee7bc7d 100644 --- a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts @@ -21,6 +21,7 @@ import { TransferAcknowledge, TxData, } from '../iface'; +import { broadridgeDescriptorFromCommand } from '../broadridge'; import utils from '../utils'; import { DUMMY_HASH, HASHING_SCHEME_VERSION, SIGNATURE_ALGORITHM_SPEC, SIGNATURE_FORMAT } from '../constant'; @@ -118,7 +119,7 @@ export class Transaction extends BaseTransaction { }; return Buffer.from(JSON.stringify(minData)).toString('base64'); } - if (this._type === TransactionType.AllocationRequest) { + if (this._type === TransactionType.AllocationRequest && !this._prepareCommand) { if (!this._allocationRequestData) { throw new InvalidTransactionError('AllocationRequestData is not set'); } @@ -216,7 +217,7 @@ export class Transaction extends BaseTransaction { result.cosignDelegationProposalData = this._cosignDelegationProposalData; return result; } - if (this._type === TransactionType.AllocationRequest) { + if (this._type === TransactionType.AllocationRequest && !this._prepareCommand) { if (!this._allocationRequestData) { throw new InvalidTransactionError('AllocationRequestData is not set'); } @@ -238,6 +239,18 @@ export class Transaction extends BaseTransaction { if (commandSummary) { result.cantonCommand = commandSummary; } + try { + const parsedInfo = utils.parseRawCantonTransactionData(this._prepareCommand.preparedTransaction, this.type); + if (parsedInfo.broadridgeAllocationRequestData) { + result.broadridgeAllocationRequestData = parsedInfo.broadridgeAllocationRequestData; + } + if (parsedInfo.broadridgeSettlementAuthorizationData) { + result.broadridgeSettlementAuthorizationData = parsedInfo.broadridgeSettlementAuthorizationData; + result.broadridge = parsedInfo.broadridgeSettlementAuthorizationData.descriptor; + } + } catch { + // Generic CantonCommand callers retain the existing summary when the command is not Broadridge. + } return result; } if (!this._prepareCommand || !this._prepareCommand.preparedTransaction) { @@ -259,6 +272,13 @@ export class Transaction extends BaseTransaction { if (parsedInfo.token) { result.token = parsedInfo.token; } + if (parsedInfo.broadridgeAllocationRequestData) { + result.broadridgeAllocationRequestData = parsedInfo.broadridgeAllocationRequestData; + } + if (parsedInfo.broadridgeSettlementAuthorizationData) { + result.broadridgeSettlementAuthorizationData = parsedInfo.broadridgeSettlementAuthorizationData; + result.broadridge = parsedInfo.broadridgeSettlementAuthorizationData.descriptor; + } return result; } @@ -266,7 +286,7 @@ export class Transaction extends BaseTransaction { if ( this._type === TransactionType.TransferAcknowledge || this._type === TransactionType.CosignDelegationProposal || - this._type === TransactionType.AllocationRequest || + (this._type === TransactionType.AllocationRequest && !this._prepareCommand) || this._type === TransactionType.EndInvestorOnboardingOffer ) { return Buffer.from(DUMMY_HASH, 'base64'); @@ -285,7 +305,15 @@ export class Transaction extends BaseTransaction { try { const decoded: TransactionBroadcastData = JSON.parse(Buffer.from(rawTx, 'base64').toString('utf8')); this.id = decoded.submissionId; - this.transactionType = TransactionType[decoded.txType]; + const txTypeAliases: Record = { + AllocationRequest_Accept: TransactionType.AllocationRequest, + AllocationRequest_Reject: TransactionType.AllocationReject, + AllocationRequest_Withdraw: TransactionType.AllocationRequest, + Archive: TransactionType.AllocationRequest, + Allocation_ExecuteTransfer: TransactionType.AllocationAllocate, + Allocation_Cancel: TransactionType.AllocationAllocateWithdrawn, + }; + this.transactionType = TransactionType[decoded.txType] ?? txTypeAliases[decoded.txType]; if (this.type === TransactionType.TransferAcknowledge) { if (decoded.acknowledgeData) { this.acknowledgeData = decoded.acknowledgeData; @@ -294,7 +322,7 @@ export class Transaction extends BaseTransaction { if (decoded.cosignDelegationProposalData) { this.cosignDelegationProposalData = decoded.cosignDelegationProposalData; } - } else if (this.type === TransactionType.AllocationRequest) { + } else if (this.type === TransactionType.AllocationRequest && !decoded.prepareCommandResponse) { if (decoded.allocationRequestData) { this.allocationRequestData = decoded.allocationRequestData; } @@ -409,6 +437,29 @@ export class Transaction extends BaseTransaction { inputAmount = txData.amount; break; } + case TransactionType.AllocationRequest: + case TransactionType.AllocationReject: { + const txData = this.toJson(); + return { + id: this.id, + displayOrder, + outputs: [], + outputAmount: '0', + inputs: [], + inputAmount: '0', + changeOutputs: [], + changeAmount: '0', + fee: { fee: '0' }, + type: this.type, + ...(txData.broadridge ? { broadridge: txData.broadridge } : {}), + ...(txData.broadridgeAllocationRequestData + ? { broadridgeAllocationRequestData: txData.broadridgeAllocationRequestData } + : {}), + ...(txData.broadridgeSettlementAuthorizationData + ? { broadridgeSettlementAuthorizationData: txData.broadridgeSettlementAuthorizationData } + : {}), + }; + } case TransactionType.CantonCommand: { const commandSummary = this.buildCantonCommandSummary(); const explanation: TransactionExplanation = { @@ -425,6 +476,18 @@ export class Transaction extends BaseTransaction { }; if (commandSummary) { explanation.cantonCommand = commandSummary; + if (commandSummary.broadridge) explanation.broadridge = commandSummary.broadridge; + } + try { + const txData = this.toJson(); + if (txData.broadridgeAllocationRequestData) { + explanation.broadridgeAllocationRequestData = txData.broadridgeAllocationRequestData; + } + if (txData.broadridgeSettlementAuthorizationData) { + explanation.broadridgeSettlementAuthorizationData = txData.broadridgeSettlementAuthorizationData; + } + } catch { + // A malformed generic command still retains its validated command summary. } return explanation; } @@ -489,6 +552,8 @@ export class Transaction extends BaseTransaction { }; if (info.choice !== undefined) summary.choice = info.choice; if (info.contractId !== undefined && info.contractId !== '') summary.contractId = info.contractId; + const broadridge = broadridgeDescriptorFromCommand(info); + if (broadridge) summary.broadridge = broadridge; return summary; } catch { return undefined; diff --git a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts index 9600ac27134..8c4941eb4a1 100644 --- a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts @@ -9,6 +9,7 @@ import { AllocationAllocateBuilder } from './allocationAllocateBuilder'; import { AllocationAllocateWithdrawnBuilder } from './allocationAllocateWithdrawnBuilder'; import { AllocationRejectBuilder } from './allocationRejectBuilder'; import { AllocationRequestBuilder } from './allocationRequestBuilder'; +import { BroadridgeSettlementAuthorizationBuilder } from './broadridgeSettlementAuthorizationBuilder'; import { CantonCommandBuilder } from './cantonCommandBuilder'; import { EndInvestorOnboardingOfferBuilder } from './endInvestorOnboardingOfferBuilder'; import { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder'; @@ -111,6 +112,13 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(tx, new CantonCommandBuilder(this._coinConfig)); } + getBroadridgeSettlementAuthorizationBuilder(tx?: Transaction): BroadridgeSettlementAuthorizationBuilder { + return TransactionBuilderFactory.initializeBuilder( + tx, + new BroadridgeSettlementAuthorizationBuilder(this._coinConfig) + ); + } + getEndInvestorOnboardingOfferBuilder(tx?: Transaction): EndInvestorOnboardingOfferBuilder { return TransactionBuilderFactory.initializeBuilder(tx, new EndInvestorOnboardingOfferBuilder(this._coinConfig)); } diff --git a/modules/sdk-coin-canton/src/lib/utils.ts b/modules/sdk-coin-canton/src/lib/utils.ts index 1538055afe8..8f7fcc8272a 100644 --- a/modules/sdk-coin-canton/src/lib/utils.ts +++ b/modules/sdk-coin-canton/src/lib/utils.ts @@ -8,7 +8,19 @@ import { computePreparedTransaction } from '../../resources/hash/hash.js'; import { PreparedTransaction } from '../../resources/proto/preparedTransaction.js'; import { CryptoKeyFormat, SigningAlgorithmSpec, SigningKeySpec } from './constant'; -import { CantonCommandInfo, PreparedTransaction as IPreparedTransaction, PreparedTxnParsedInfo } from './iface'; +import { + BroadridgeAllocationRequestData, + BroadridgeSettlementAuthorizationData, + CantonCommandInfo, + PreparedTransaction as IPreparedTransaction, + PreparedTxnParsedInfo, +} from './iface'; +import { + broadridgeDescriptorFromCommand, + parseBroadridgeAllocationOutcome, + parseBroadridgeAllocationRequest, + parseBroadridgeSettlementAuthorization, +} from './broadridge'; import { RecordField, Value } from './resourcesInterface'; export class Utils implements BaseUtils { @@ -99,6 +111,30 @@ export class Utils implements BaseUtils { let memoId: string | undefined; let instrumentId: string | undefined; let instrumentAdmin: string | undefined; + let broadridgeAllocationRequestData: BroadridgeAllocationRequestData | undefined; + let broadridgeSettlementAuthorizationData: BroadridgeSettlementAuthorizationData | undefined; + + try { + const commandInfo = this.extractCantonCommandInfo(rawData); + const allocationRequest = parseBroadridgeAllocationRequest(commandInfo); + const allocationOutcome = parseBroadridgeAllocationOutcome(commandInfo); + const descriptor = broadridgeDescriptorFromCommand(commandInfo); + if (allocationRequest) { + broadridgeAllocationRequestData = allocationRequest; + } else if (allocationOutcome && descriptor) { + broadridgeAllocationRequestData = { + version: descriptor.standard === 'CIP-112' ? 'v2' : 'v1', + templateId: descriptor.templateId, + requestCid: allocationOutcome.requestCid, + outcome: allocationOutcome, + raw: {}, + }; + } + broadridgeSettlementAuthorizationData = parseBroadridgeSettlementAuthorization(commandInfo); + } catch { + // Transfer transactions often have several roots or no Broadridge command. The + // venue-specific parser is best-effort and the existing transfer parser remains authoritative. + } /** * Returns the argument fields of the first create node matching the given entity name. @@ -595,6 +631,33 @@ export class Utils implements BaseUtils { } } + if (!sender || !receiver || !amount) { + const executeFields = findExerciseNodeFields('Allocation_ExecuteTransfer'); + const cancelFields = findExerciseNodeFields('Allocation_Cancel'); + const effectFields = executeFields ?? cancelFields; + if (effectFields) { + for (const fieldName of ['transfer', 'transferLeg', 'allocation']) { + const field = getField(effectFields, fieldName); + if (field?.oneofKind !== 'record') continue; + const nestedFields = field.record?.fields ?? []; + const transferField = getField(nestedFields, 'transferLeg'); + const transferFields = + transferField?.oneofKind === 'record' ? transferField.record?.fields ?? [] : nestedFields; + extractFromTransferRecord(transferFields, 'instrumentId', 'admin'); + if (sender && receiver && amount) break; + } + } + } + + if (broadridgeAllocationRequestData || broadridgeSettlementAuthorizationData) { + return { + sender, + receiver, + amount: amount ? this.convertAmountToLowestUnit(new BigNumber(amount)) : '0', + broadridgeAllocationRequestData, + broadridgeSettlementAuthorizationData, + }; + } if (!sender || !receiver || !amount) { const missingFields: string[] = []; if (!sender) missingFields.push('sender'); diff --git a/modules/sdk-coin-canton/test/unit/builder/allocationRequest/allocationRequestBuilder.ts b/modules/sdk-coin-canton/test/unit/builder/allocationRequest/allocationRequestBuilder.ts index 858be8b6cbb..94073219ee0 100644 --- a/modules/sdk-coin-canton/test/unit/builder/allocationRequest/allocationRequestBuilder.ts +++ b/modules/sdk-coin-canton/test/unit/builder/allocationRequest/allocationRequestBuilder.ts @@ -2,9 +2,10 @@ import assert from 'assert'; import should from 'should'; import { coins } from '@bitgo/statics'; +import { PublicKey } from '@bitgo/sdk-core'; import { AllocationRequestBuilder, Transaction } from '../../../../src'; -import { AllocationRequest } from '../../../../src/lib/iface'; +import { AllocationRequest, CantonPrepareCommandResponse } from '../../../../src/lib/iface'; const updateId = '12205b0b024b6f72c5484696271e8e87d7c1e0cc7bed5e9c901088b41ebe97a09a43'; const operatorId = 'treasury-tokenization-1::1220906cb6b369890324880c977a44d10e91b70b4cc9abe62d031bdafcf5dabecf89'; @@ -269,7 +270,7 @@ describe('AllocationRequest Builder', () => { assert.throws(() => txBuilder.toRequestObject(), /token is missing/); }); - it('should throw if receiveToken is missing for a non-fee request', function () { + it('should allow a payment-only request without receive fields', function () { const txBuilder = new AllocationRequestBuilder(coins.get('tcanton')); txBuilder.initBuilder(new Transaction(coins.get('tcanton'))); txBuilder @@ -282,10 +283,11 @@ describe('AllocationRequest Builder', () => { .receiverPartyId(receiverPartyId) .amount(amount) .token(token) - .receiveAmount(receiveAmount) .allocateBefore(allocateBefore) .settleBefore(settleBefore); - assert.throws(() => txBuilder.toRequestObject(), /receiveToken is missing/); + const requestObj = txBuilder.toRequestObject(); + assert.equal(requestObj.receiveToken, undefined); + assert.equal(requestObj.receiveAmount, undefined); }); it('should throw if amount was never set', function () { @@ -307,7 +309,7 @@ describe('AllocationRequest Builder', () => { assert.throws(() => txBuilder.toRequestObject(), /amount is missing/); }); - it('should throw if receiveAmount was never set for a non-fee request', function () { + it('should allow a payment-only request without receive amount', function () { const txBuilder = new AllocationRequestBuilder(coins.get('tcanton')); txBuilder.initBuilder(new Transaction(coins.get('tcanton'))); txBuilder @@ -323,7 +325,8 @@ describe('AllocationRequest Builder', () => { .receiveToken(receiveToken) .allocateBefore(allocateBefore) .settleBefore(settleBefore); - assert.throws(() => txBuilder.toRequestObject(), /receiveAmount is missing/); + const requestObj = txBuilder.toRequestObject(); + assert.equal(requestObj.receiveAmount, undefined); }); it('should throw if allocateBefore is missing', function () { @@ -558,15 +561,19 @@ describe('AllocationRequest Builder', () => { assert.throws(() => txBuilder.settleBefore(''), /settleBefore must be a non-empty string/); }); - // --- not-implemented methods --- + // --- prepared transaction methods --- - it('should throw on setTransaction', function () { + it('should set a prepared transaction', function () { const txBuilder = new AllocationRequestBuilder(coins.get('tcanton')); - assert.throws(() => txBuilder.setTransaction({} as any), /Not implemented/); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + const prepare = { preparedTransactionHash: 'hash', hashingSchemeVersion: 'v' } as CantonPrepareCommandResponse; + txBuilder.setTransaction(prepare); + assert.equal(tx.prepareCommand, prepare); }); - it('should throw on addSignature', function () { + it('should reject signing before a transaction is initialized', function () { const txBuilder = new AllocationRequestBuilder(coins.get('tcanton')); - assert.throws(() => txBuilder.addSignature({} as any, Buffer.from('')), /Not implemented/); + assert.throws(() => txBuilder.addSignature({} as PublicKey, Buffer.from('')), /transaction is empty/); }); }); diff --git a/modules/sdk-coin-canton/test/unit/builder/settlementAuthorization/settlementAuthorizationBuilder.ts b/modules/sdk-coin-canton/test/unit/builder/settlementAuthorization/settlementAuthorizationBuilder.ts new file mode 100644 index 00000000000..546f1aad2c3 --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/builder/settlementAuthorization/settlementAuthorizationBuilder.ts @@ -0,0 +1,164 @@ +import assert from 'assert'; + +import { coins } from '@bitgo/statics'; + +import { BroadridgeSettlementAuthorizationBuilder, Transaction } from '../../../../src'; +import { broadridgeDescriptorFromCommand, parseBroadridgeAllocationRequest } from '../../../../src/lib/broadridge'; +import { CantonCommandInfo } from '../../../../src/lib/iface'; + +const packageId = 'broadridge-settlement-package'; +const standingDelegationTemplate = `${packageId}:Broadridge.Token.Implementations:StandingDelegationImpl_v1`; +const proposalTemplate = `${packageId}:Broadridge.Token.Implementations:SettlementProposalImpl_v1`; +const agreementTemplate = `${packageId}:Broadridge.Token.Implementations:SettlementAgreementImpl_v1`; +const client = 'client::1220aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const executor = 'executor::1220bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const proposal = '0010-proposal-cid'; + +function builder(): BroadridgeSettlementAuthorizationBuilder { + const result = new BroadridgeSettlementAuthorizationBuilder(coins.get('tcanton')); + result.initBuilder(new Transaction(coins.get('tcanton'))); + return result; +} + +describe('Broadridge Settlement Authorization Builder', function () { + it('builds a package-qualified standing delegation create command', function () { + const request = builder() + .createStandingDelegation({ + commandId: 'create-delegation', + templateId: standingDelegationTemplate, + actAs: [client], + choiceArgument: { client, executor, meta: { source: 'test' } }, + }) + .toRequestObject(); + + assert.deepStrictEqual(request.command, { + CreateCommand: { + templateId: standingDelegationTemplate, + createArguments: { client, executor, meta: { source: 'test' } }, + }, + }); + assert.equal(request.broadridge?.standard, 'CIP-56'); + assert.equal(request.broadridge?.role, 'client'); + assert.equal(request.broadridge?.entityName, 'StandingDelegationImpl_v1'); + }); + + it('builds role-specific proposal and agreement choices', function () { + const proposalRequest = builder() + .acceptSettlementProposal({ + commandId: 'accept-proposal', + templateId: proposalTemplate, + contractId: proposal, + actAs: [client], + choiceArgument: { extraArgs: { meta: { reason: 'approved' } } }, + }) + .toRequestObject(); + assert.equal(proposalRequest.broadridge?.choice, 'SettlementProposal_Accept'); + assert.equal(proposalRequest.broadridge?.role, 'trading-party'); + + const agreementRequest = builder() + .settleAgreement({ + commandId: 'settle-agreement', + templateId: agreementTemplate, + contractId: '0010-agreement-cid', + actAs: [executor], + allocationCids: ['0010-allocation-a', '0010-allocation-b'], + }) + .toRequestObject(); + assert.equal(agreementRequest.broadridge?.choice, 'SettlementAgreement_Settle'); + assert.deepStrictEqual(agreementRequest.broadridge?.allocationCids, ['0010-allocation-a', '0010-allocation-b']); + }); + + it('rejects unknown template modules instead of falling back to another venue', function () { + assert.throws( + () => + builder().settleAgreement({ + commandId: 'bad-command', + templateId: `${packageId}:TradeWeb.Module:SettlementAgreementImpl_v1`, + contractId: '0010-agreement-cid', + actAs: [executor], + }), + /templateId must identify Broadridge/ + ); + }); + it('keeps payment-only V1 requests without a fictional receive leg', function () { + const info: CantonCommandInfo = { + kind: 'CreateCommand', + templateId: { + packageId, + moduleName: 'Broadridge.Token.Implementations.AllocationRequestImpl_v1', + entityName: 'AllocationRequestImpl_v1', + }, + argument: { + executor, + settlement: { executor, reference: 'payment-only-settlement', allocateBefore: '2026-10-21T00:00:00Z' }, + transferLegs: { + 'cash-leg': { + sender: client, + receiver: executor, + amount: '12.5', + instrumentId: { admin: executor, id: 'USD' }, + }, + }, + meta: { source: 'broadridge' }, + }, + }; + const parsed = parseBroadridgeAllocationRequest(info); + assert.equal(parsed?.version, 'v1'); + assert.equal(parsed?.transferLegs?.length, 1); + assert.equal(parsed?.transferLegs?.[0].legId, 'cash-leg'); + assert.equal(parsed?.transferLegs?.[0].receiverPartyId, executor); + }); + + it('retains receiver-only V2 sides with explicit no-input semantics', function () { + const info: CantonCommandInfo = { + kind: 'CreateCommand', + templateId: { + packageId, + moduleName: 'Broadridge.Token.Implementations.AllocationRequestImpl_v2', + entityName: 'AllocationRequestImpl_v2', + }, + argument: { + executor, + settlement: { executors: [executor], id: 'settlement-v2', meta: { trade: 'T-1' } }, + allocations: [ + { + admin: executor, + authorizer: { owner: client, provider: executor, id: 'account-1' }, + transferLegSides: [ + { + side: 'ReceiverSide', + legId: 'receive-leg', + otherside: { owner: executor }, + amount: '1', + instrumentId: 'USD', + }, + ], + committed: true, + nextIterationFunding: { amount: '0' }, + }, + ], + }, + }; + const parsed = parseBroadridgeAllocationRequest(info); + assert.equal(parsed?.version, 'v2'); + assert.equal(parsed?.allocations?.[0].receiverOnly, true); + assert.deepStrictEqual(parsed?.allocations?.[0].inputHoldingCids, []); + assert.equal(parsed?.allocations?.[0].transferLegSides?.[0].side, 'ReceiverSide'); + }); + + it('rejects unknown Broadridge choices', function () { + const descriptor = broadridgeDescriptorFromCommand({ + kind: 'ExerciseCommand', + templateId: { + packageId, + moduleName: 'Broadridge.Token.Implementations.SettlementAgreementImpl_v1', + entityName: 'SettlementAgreementImpl_v1', + }, + argument: {}, + choice: 'UnknownChoice', + contractId: 'agreement-cid', + actingParties: [executor], + }); + assert.equal(descriptor, undefined); + }); +});