From fdb1754d8d3703aecb503036d0fd67323b196208 Mon Sep 17 00:00:00 2001 From: Ravi Hegde Date: Tue, 12 May 2026 12:32:42 +0530 Subject: [PATCH] feat(sdk-coin-canton): added cosignDelegationProposal builder Ticket: CHALO-431 --- .../lib/cosignDelegationProposalBuilder.ts | 105 ++++++++++++++++++ modules/sdk-coin-canton/src/lib/iface.ts | 8 ++ modules/sdk-coin-canton/src/lib/index.ts | 1 + .../src/lib/transaction/transaction.ts | 40 ++++++- .../src/lib/transactionBuilderFactory.ts | 8 ++ .../cosignDelegationProposalBuilder.ts | 85 ++++++++++++++ .../sdk-core/src/account-lib/baseCoin/enum.ts | 2 + 7 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 modules/sdk-coin-canton/src/lib/cosignDelegationProposalBuilder.ts create mode 100644 modules/sdk-coin-canton/test/unit/builder/cosignDelegationProposal/cosignDelegationProposalBuilder.ts diff --git a/modules/sdk-coin-canton/src/lib/cosignDelegationProposalBuilder.ts b/modules/sdk-coin-canton/src/lib/cosignDelegationProposalBuilder.ts new file mode 100644 index 0000000000..a813461acb --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/cosignDelegationProposalBuilder.ts @@ -0,0 +1,105 @@ +import { PublicKey, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { CantonPrepareCommandResponse, CosignDelegationProposal } from './iface'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction/transaction'; + +export class CosignDelegationProposalBuilder extends TransactionBuilder { + private _contractId: string; + private _operatorId: string; + private _packageName?: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + this.setTransactionType(); + } + + get transactionType(): TransactionType { + return TransactionType.CosignDelegationProposal; + } + + setTransactionType(): void { + this.transaction.transactionType = TransactionType.CosignDelegationProposal; + } + + setTransaction(transaction: CantonPrepareCommandResponse): void { + throw new Error('Not implemented!'); + } + + /** @inheritDoc */ + addSignature(publicKey: PublicKey, signature: Buffer): void { + throw new Error('Not implemented!'); + } + + /** + * Sets the contract id of the delegation proposal to cosign + * @param id - canton contract id + * @returns The current builder instance for chaining. + * @throws Error if id is empty. + */ + contractId(id: string): this { + if (!id || !id.trim()) { + throw new Error('contractId must be a non-empty string'); + } + this._contractId = id.trim(); + return this; + } + + /** + * Sets the operator party id + * @param id - operator party id + * @returns The current builder instance for chaining. + * @throws Error if id is empty. + */ + operatorId(id: string): this { + if (!id || !id.trim()) { + throw new Error('operatorId must be a non-empty string'); + } + this._operatorId = id.trim(); + return this; + } + + /** + * Sets the optional package name + * @param name - package name + * @returns The current builder instance for chaining. + */ + packageName(name: string): this { + this._packageName = name; + return this; + } + + /** + * Builds and returns the CosignDelegationProposal object from the builder's internal state. + * + * @returns {CosignDelegationProposal} - A fully constructed and validated request object. + * @throws {Error} If any required field is missing or fails validation. + */ + toRequestObject(): CosignDelegationProposal { + this.validate(); + + const result: CosignDelegationProposal = { + contractId: this._contractId, + operatorId: this._operatorId, + }; + if (this._packageName !== undefined) { + result.packageName = this._packageName; + } + return result; + } + + /** + * Validates the internal state of the builder before building the request object. + * + * @private + * @throws {Error} If any required field is missing or invalid. + */ + private validate(): void { + if (!this._contractId) throw new Error('contractId is missing'); + if (!this._operatorId) throw new Error('operatorId is missing'); + } +} diff --git a/modules/sdk-coin-canton/src/lib/iface.ts b/modules/sdk-coin-canton/src/lib/iface.ts index d2e2fdd0a5..b668688213 100644 --- a/modules/sdk-coin-canton/src/lib/iface.ts +++ b/modules/sdk-coin-canton/src/lib/iface.ts @@ -21,6 +21,7 @@ export interface TxData { receiver: string; amount: string; acknowledgeData?: TransferAcknowledge; + cosignDelegationProposalData?: CosignDelegationProposal; memoId?: string; token?: string; } @@ -112,6 +113,7 @@ export interface PartySignature { export interface TransactionBroadcastData { acknowledgeData?: TransferAcknowledge; + cosignDelegationProposalData?: CosignDelegationProposal; prepareCommandResponse?: CantonPrepareCommandResponse; txType: string; preparedTransaction?: string; @@ -151,6 +153,12 @@ export interface TransferAcknowledge { updateId: string; } +export interface CosignDelegationProposal { + contractId: string; + operatorId: string; + packageName?: string; +} + export interface CantonTransferRequest { commandId: string; senderPartyId: string; diff --git a/modules/sdk-coin-canton/src/lib/index.ts b/modules/sdk-coin-canton/src/lib/index.ts index 1d6d1c468d..d36d376934 100644 --- a/modules/sdk-coin-canton/src/lib/index.ts +++ b/modules/sdk-coin-canton/src/lib/index.ts @@ -1,6 +1,7 @@ import * as Utils from './utils'; import * as Interface from './iface'; +export { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder'; export { KeyPair } from './keyPair'; export { OneStepPreApprovalBuilder } from './oneStepPreApprovalBuilder'; export { Transaction } from './transaction/transaction'; diff --git a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts index d60f144785..a090877594 100644 --- a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts @@ -9,6 +9,7 @@ import { import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { CantonPrepareCommandResponse, + CosignDelegationProposal, MultiHashSignature, PartySignature, PreparedTxnParsedInfo, @@ -24,6 +25,7 @@ export class Transaction extends BaseTransaction { private _prepareCommand: CantonPrepareCommandResponse; private _signerFingerprint: string; private _acknowledgeData: TransferAcknowledge; + private _cosignDelegationProposalData: CosignDelegationProposal; constructor(coinConfig: Readonly) { super(coinConfig); @@ -45,6 +47,10 @@ export class Transaction extends BaseTransaction { this._acknowledgeData = data; } + set cosignDelegationProposalData(data: CosignDelegationProposal) { + this._cosignDelegationProposalData = data; + } + get id(): string { if (!this._id) { throw new InvalidTransactionError('transaction is is not set'); @@ -83,6 +89,17 @@ export class Transaction extends BaseTransaction { }; return Buffer.from(JSON.stringify(minData)).toString('base64'); } + if (this._type === TransactionType.CosignDelegationProposal) { + if (!this._cosignDelegationProposalData) { + throw new InvalidTransactionError('CosignDelegationProposalData is not set'); + } + const minData: TransactionBroadcastData = { + txType: TransactionType[this._type], + submissionId: this.id, + cosignDelegationProposalData: this._cosignDelegationProposalData, + }; + return Buffer.from(JSON.stringify(minData)).toString('base64'); + } if (!this._prepareCommand) { throw new InvalidTransactionError('Empty transaction data'); } @@ -144,6 +161,13 @@ export class Transaction extends BaseTransaction { result.acknowledgeData = this._acknowledgeData; return result; } + if (this._type === TransactionType.CosignDelegationProposal) { + if (!this._cosignDelegationProposalData) { + throw new InvalidTransactionError('CosignDelegationProposalData is not set'); + } + result.cosignDelegationProposalData = this._cosignDelegationProposalData; + return result; + } if (!this._prepareCommand || !this._prepareCommand.preparedTransaction) { throw new InvalidTransactionError('Empty transaction data'); } @@ -167,7 +191,7 @@ export class Transaction extends BaseTransaction { } get signablePayload(): Buffer { - if (this._type === TransactionType.TransferAcknowledge) { + if (this._type === TransactionType.TransferAcknowledge || this._type === TransactionType.CosignDelegationProposal) { return Buffer.from(DUMMY_HASH, 'base64'); } if (!this._prepareCommand) { @@ -181,7 +205,15 @@ export class Transaction extends BaseTransaction { const decoded: TransactionBroadcastData = JSON.parse(Buffer.from(rawTx, 'base64').toString('utf8')); this.id = decoded.submissionId; this.transactionType = TransactionType[decoded.txType]; - if (this.type !== TransactionType.TransferAcknowledge) { + if (this.type === TransactionType.TransferAcknowledge) { + if (decoded.acknowledgeData) { + this.acknowledgeData = decoded.acknowledgeData; + } + } else if (this.type === TransactionType.CosignDelegationProposal) { + if (decoded.cosignDelegationProposalData) { + this.cosignDelegationProposalData = decoded.cosignDelegationProposalData; + } + } else { if (decoded.prepareCommandResponse) { this.prepareCommand = decoded.prepareCommandResponse; this.loadInputsAndOutputs(); @@ -190,10 +222,6 @@ export class Transaction extends BaseTransaction { this.signerFingerprint = decoded.partySignatures.signatures[0].party.split('::')[1]; this.signatures = decoded.partySignatures.signatures[0].signatures[0].signature; } - } else { - if (decoded.acknowledgeData) { - this.acknowledgeData = decoded.acknowledgeData; - } } } catch (e) { throw new InvalidTransactionError('Unable to parse raw transaction data'); diff --git a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts index d07d2a37f0..9edefc926e 100644 --- a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts @@ -5,6 +5,7 @@ import { TransactionType, } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder'; import { OneStepPreApprovalBuilder } from './oneStepPreApprovalBuilder'; import { TransferAcceptanceBuilder } from './transferAcceptanceBuilder'; import { TransferAcknowledgeBuilder } from './transferAcknowledgeBuilder'; @@ -42,6 +43,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { case TransactionType.TransferAcknowledge: { return this.getTransferAcknowledgeBuilder(tx); } + case TransactionType.CosignDelegationProposal: { + return this.getCosignDelegationProposalBuilder(tx); + } case TransactionType.TransferOfferWithdrawn: { return this.getTransferOfferWithdrawnBuilder(tx); } @@ -67,6 +71,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(tx, new TransferAcknowledgeBuilder(this._coinConfig)); } + getCosignDelegationProposalBuilder(tx?: Transaction): CosignDelegationProposalBuilder { + return TransactionBuilderFactory.initializeBuilder(tx, new CosignDelegationProposalBuilder(this._coinConfig)); + } + getTransferOfferWithdrawnBuilder(tx?: Transaction): TransferOfferWithdrawnBuilder { return TransactionBuilderFactory.initializeBuilder(tx, new TransferOfferWithdrawnBuilder(this._coinConfig)); } diff --git a/modules/sdk-coin-canton/test/unit/builder/cosignDelegationProposal/cosignDelegationProposalBuilder.ts b/modules/sdk-coin-canton/test/unit/builder/cosignDelegationProposal/cosignDelegationProposalBuilder.ts new file mode 100644 index 0000000000..03bb440da0 --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/builder/cosignDelegationProposal/cosignDelegationProposalBuilder.ts @@ -0,0 +1,85 @@ +import assert from 'assert'; +import should from 'should'; + +import { coins } from '@bitgo/statics'; + +import { CosignDelegationProposalBuilder, Transaction } from '../../../../src'; +import { CosignDelegationProposal } from '../../../../src/lib/iface'; + +const contractId = + '001b549bfa833bab661ab30e4d0a3ab0ec01fcc4a2bef5369795f4928147706353ca1112205a8d0e780cf3b3115cf8be0d6315f4aed6a1c25b67e8c5d64cf9848d0458fd17'; +const operatorId = '12205::12205b4e3537a95126d90604592344d8ad3c3ddccda4f79901954280ee19c576714d'; +const packageName = 'splice-amulet'; +const submissionId = '12205b4e3537a95126d90604592344d8ad3c3ddccda4f79901954280ee19c576714d'; + +describe('CosignDelegationProposal Builder', () => { + it('should get the cosign delegation proposal request object', function () { + const txBuilder = new CosignDelegationProposalBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + tx.id = submissionId; + txBuilder.initBuilder(tx); + txBuilder.contractId(contractId).operatorId(operatorId).packageName(packageName); + const requestObj: CosignDelegationProposal = txBuilder.toRequestObject(); + should.exist(requestObj); + assert.equal(requestObj.contractId, contractId); + assert.equal(requestObj.operatorId, operatorId); + assert.equal(requestObj.packageName, packageName); + }); + + it('should get the cosign delegation proposal request object without packageName', function () { + const txBuilder = new CosignDelegationProposalBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + tx.id = submissionId; + txBuilder.initBuilder(tx); + txBuilder.contractId(contractId).operatorId(operatorId); + const requestObj: CosignDelegationProposal = txBuilder.toRequestObject(); + should.exist(requestObj); + assert.equal(requestObj.contractId, contractId); + assert.equal(requestObj.operatorId, operatorId); + assert.equal(requestObj.packageName, undefined); + }); + + it('should throw if contractId is missing', function () { + const txBuilder = new CosignDelegationProposalBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + tx.id = submissionId; + txBuilder.initBuilder(tx); + txBuilder.operatorId(operatorId); + assert.throws(() => txBuilder.toRequestObject(), /contractId is missing/); + }); + + it('should throw if operatorId is missing', function () { + const txBuilder = new CosignDelegationProposalBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + tx.id = submissionId; + txBuilder.initBuilder(tx); + txBuilder.contractId(contractId); + assert.throws(() => txBuilder.toRequestObject(), /operatorId is missing/); + }); + + it('should throw if contractId is empty string', function () { + const txBuilder = new CosignDelegationProposalBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + tx.id = submissionId; + txBuilder.initBuilder(tx); + assert.throws(() => txBuilder.contractId(''), /contractId must be a non-empty string/); + }); + + it('should throw if operatorId is empty string', function () { + const txBuilder = new CosignDelegationProposalBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + tx.id = submissionId; + txBuilder.initBuilder(tx); + assert.throws(() => txBuilder.operatorId(''), /operatorId must be a non-empty string/); + }); + + it('should throw on setTransaction', function () { + const txBuilder = new CosignDelegationProposalBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.setTransaction({} as any), /Not implemented/); + }); + + it('should throw on addSignature', function () { + const txBuilder = new CosignDelegationProposalBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.addSignature({} as any, Buffer.from('')), /Not implemented/); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index 9d7eb9afee..eb61e0f3fd 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -97,6 +97,8 @@ export enum TransactionType { TransferReject, // canton transfer offer withdrawn, 2-step TransferOfferWithdrawn, + // canton cosign delegation proposal + CosignDelegationProposal, // trx FREEZE,