diff --git a/modules/sdk-coin-canton/src/canton.ts b/modules/sdk-coin-canton/src/canton.ts index baa4297507..e6fb361f77 100644 --- a/modules/sdk-coin-canton/src/canton.ts +++ b/modules/sdk-coin-canton/src/canton.ts @@ -114,6 +114,8 @@ export class Canton extends BaseCoin { case TransactionType.TransferReject: case TransactionType.TransferAcknowledge: case TransactionType.TransferOfferWithdrawn: + case TransactionType.CosignDelegationAccept: + case TransactionType.CosignDelegationProposal: // There is no input for these type of transactions, so always return true. return true; case TransactionType.OneStepPreApproval: diff --git a/modules/sdk-coin-canton/src/lib/cosignDelegationAcceptBuilder.ts b/modules/sdk-coin-canton/src/lib/cosignDelegationAcceptBuilder.ts new file mode 100644 index 0000000000..9a3c9ac82c --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/cosignDelegationAcceptBuilder.ts @@ -0,0 +1,120 @@ +import { InvalidTransactionError, PublicKey, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { CantonPrepareCommandResponse, CantonTransferAcceptRejectRequest } from './iface'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction/transaction'; +import utils from './utils'; + +export class CosignDelegationAcceptBuilder extends TransactionBuilder { + private _commandId: string; + private _contractId: string; + private _actAsPartyId: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + this.setTransactionType(); + } + + get transactionType(): TransactionType { + return TransactionType.CosignDelegationAccept; + } + + setTransactionType(): void { + this.transaction.transactionType = TransactionType.CosignDelegationAccept; + } + + setTransaction(transaction: CantonPrepareCommandResponse): void { + this.transaction.prepareCommand = transaction; + } + + /** @inheritDoc */ + addSignature(publicKey: PublicKey, signature: Buffer): void { + if (!this.transaction) { + throw new InvalidTransactionError('transaction is empty!'); + } + this._signatures.push({ publicKey, signature }); + const pubKeyBase64 = utils.getBase64FromHex(publicKey.pub); + this.transaction.signerFingerprint = utils.getAddressFromPublicKey(pubKeyBase64); + this.transaction.signatures = signature.toString('base64'); + } + + /** + * Sets the unique command id for the cosign delegation accept + * Also sets the _id of the transaction + * + * @param id - A uuid + * @returns The current builder instance for chaining. + * @throws Error if id is empty. + */ + commandId(id: string): this { + if (!id || !id.trim()) { + throw new Error('commandId must be a non-empty string'); + } + this._commandId = id.trim(); + this.transaction.id = id.trim(); + return this; + } + + /** + * Sets the contract id of the delegation proposal to accept + * @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 party acting as the acceptor + * + * @param id - the actor party id (address) + * @returns The current builder instance for chaining. + * @throws Error if id is empty. + */ + actAs(id: string): this { + if (!id || !id.trim()) { + throw new Error('actAsPartyId must be a non-empty string'); + } + this._actAsPartyId = id.trim(); + return this; + } + + /** + * Builds and returns the CantonTransferAcceptRejectRequest object from the builder's internal state. + * + * @returns {CantonTransferAcceptRejectRequest} - A fully constructed and validated request object. + * @throws {Error} If any required field is missing or fails validation. + */ + toRequestObject(): CantonTransferAcceptRejectRequest { + this.validate(); + + return { + commandId: this._commandId, + contractId: this._contractId, + verboseHashing: false, + actAs: [this._actAsPartyId], + readAs: [], + }; + } + + /** + * 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._commandId) throw new Error('commandId is missing'); + if (!this._contractId) throw new Error('contractId is missing'); + if (!this._actAsPartyId) throw new Error('actAs partyId is missing'); + } +} diff --git a/modules/sdk-coin-canton/src/lib/index.ts b/modules/sdk-coin-canton/src/lib/index.ts index d36d376934..a26b20b1df 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 { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder'; export { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder'; export { KeyPair } from './keyPair'; export { OneStepPreApprovalBuilder } from './oneStepPreApprovalBuilder'; diff --git a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts index a090877594..d24d7e0122 100644 --- a/modules/sdk-coin-canton/src/lib/transaction/transaction.ts +++ b/modules/sdk-coin-canton/src/lib/transaction/transaction.ts @@ -269,6 +269,7 @@ export class Transaction extends BaseTransaction { let inputAmount = '0'; let outputAmount = '0'; switch (this.type) { + case TransactionType.CosignDelegationAccept: case TransactionType.TransferAccept: case TransactionType.TransferReject: { const txData = this.toJson(); diff --git a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts index 9edefc926e..e2b1fe06ba 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 { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder'; import { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder'; import { OneStepPreApprovalBuilder } from './oneStepPreApprovalBuilder'; import { TransferAcceptanceBuilder } from './transferAcceptanceBuilder'; @@ -46,6 +47,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { case TransactionType.CosignDelegationProposal: { return this.getCosignDelegationProposalBuilder(tx); } + case TransactionType.CosignDelegationAccept: { + return this.getCosignDelegationAcceptBuilder(tx); + } case TransactionType.TransferOfferWithdrawn: { return this.getTransferOfferWithdrawnBuilder(tx); } @@ -75,6 +79,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(tx, new CosignDelegationProposalBuilder(this._coinConfig)); } + getCosignDelegationAcceptBuilder(tx?: Transaction): CosignDelegationAcceptBuilder { + return TransactionBuilderFactory.initializeBuilder(tx, new CosignDelegationAcceptBuilder(this._coinConfig)); + } + getTransferOfferWithdrawnBuilder(tx?: Transaction): TransferOfferWithdrawnBuilder { return TransactionBuilderFactory.initializeBuilder(tx, new TransferOfferWithdrawnBuilder(this._coinConfig)); } diff --git a/modules/sdk-coin-canton/src/lib/utils.ts b/modules/sdk-coin-canton/src/lib/utils.ts index 17684517b5..6fafae5497 100644 --- a/modules/sdk-coin-canton/src/lib/utils.ts +++ b/modules/sdk-coin-canton/src/lib/utils.ts @@ -308,6 +308,20 @@ export class Utils implements BaseUtils { break; } + case TransactionType.CosignDelegationAccept: { + // exercise CosignDelegationProposal_Accept → actingParties[0] = signer (sender) + const signerParty = findExerciseActingParty('CosignDelegationProposal_Accept'); + if (signerParty) sender = signerParty; + // CosignDelegationProposal create node → admin = receiver + const proposalFields = findCreateNodeFields('CosignDelegationProposal'); + if (proposalFields) { + const adminData = getField(proposalFields, 'admin'); + if (adminData?.oneofKind === 'party') receiver = adminData.party ?? ''; + } + amount = '0'; + break; + } + case TransactionType.TransferOfferWithdrawn: { // Canton coin: Amulet create node → owner=sender=receiver, amount.initialAmount const amuletFields = findCreateNodeFields('Amulet'); diff --git a/modules/sdk-coin-canton/test/unit/builder/cosignDelegationAccept/cosignDelegationAcceptBuilder.ts b/modules/sdk-coin-canton/test/unit/builder/cosignDelegationAccept/cosignDelegationAcceptBuilder.ts new file mode 100644 index 0000000000..dacc45e32c --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/builder/cosignDelegationAccept/cosignDelegationAcceptBuilder.ts @@ -0,0 +1,82 @@ +import assert from 'assert'; +import should from 'should'; + +import { coins } from '@bitgo/statics'; + +import { CosignDelegationAcceptBuilder, Transaction } from '../../../../src'; +import { CantonTransferAcceptRejectRequest } from '../../../../src/lib/iface'; + +const commandId = '3935a06d-3b03-41be-99a5-95b2ecaabf7d'; +const contractId = + '001b549bfa833bab661ab30e4d0a3ab0ec01fcc4a2bef5369795f4928147706353ca1112205a8d0e780cf3b3115cf8be0d6315f4aed6a1c25b67e8c5d64cf9848d0458fd17'; +const actAsPartyId = '12205::12205b4e3537a95126d90604592344d8ad3c3ddccda4f79901954280ee19c576714d'; + +describe('CosignDelegationAccept Builder', () => { + it('should get the cosign delegation accept request object', function () { + const txBuilder = new CosignDelegationAcceptBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.commandId(commandId).contractId(contractId).actAs(actAsPartyId); + const requestObj: CantonTransferAcceptRejectRequest = txBuilder.toRequestObject(); + should.exist(requestObj); + assert.equal(requestObj.commandId, commandId); + assert.equal(requestObj.contractId, contractId); + assert.equal(requestObj.actAs.length, 1); + assert.equal(requestObj.actAs[0], actAsPartyId); + assert.deepEqual(requestObj.readAs, []); + assert.equal(requestObj.verboseHashing, false); + }); + + it('should set transaction id from commandId', function () { + const txBuilder = new CosignDelegationAcceptBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.commandId(commandId); + assert.equal(txBuilder.transaction.id, commandId); + }); + + it('should throw if commandId is missing', function () { + const txBuilder = new CosignDelegationAcceptBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.contractId(contractId).actAs(actAsPartyId); + assert.throws(() => txBuilder.toRequestObject(), /commandId is missing/); + }); + + it('should throw if contractId is missing', function () { + const txBuilder = new CosignDelegationAcceptBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.commandId(commandId).actAs(actAsPartyId); + assert.throws(() => txBuilder.toRequestObject(), /contractId is missing/); + }); + + it('should throw if actAs is missing', function () { + const txBuilder = new CosignDelegationAcceptBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.commandId(commandId).contractId(contractId); + assert.throws(() => txBuilder.toRequestObject(), /actAs partyId is missing/); + }); + + it('should throw if commandId is empty string', function () { + const txBuilder = new CosignDelegationAcceptBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + assert.throws(() => txBuilder.commandId(''), /commandId must be a non-empty string/); + }); + + it('should throw if contractId is empty string', function () { + const txBuilder = new CosignDelegationAcceptBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + assert.throws(() => txBuilder.contractId(''), /contractId must be a non-empty string/); + }); + + it('should throw if actAs is empty string', function () { + const txBuilder = new CosignDelegationAcceptBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + assert.throws(() => txBuilder.actAs(''), /actAsPartyId must be a non-empty string/); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index eb61e0f3fd..16e4975e95 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -99,6 +99,8 @@ export enum TransactionType { TransferOfferWithdrawn, // canton cosign delegation proposal CosignDelegationProposal, + // canton cosign delegation accept + CosignDelegationAccept, // trx FREEZE,