diff --git a/modules/sdk-coin-canton/src/lib/allocationAllocateWithdrawnBuilder.ts b/modules/sdk-coin-canton/src/lib/allocationAllocateWithdrawnBuilder.ts new file mode 100644 index 0000000000..a3b6efa35c --- /dev/null +++ b/modules/sdk-coin-canton/src/lib/allocationAllocateWithdrawnBuilder.ts @@ -0,0 +1,140 @@ +import { InvalidTransactionError, PublicKey, TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { CantonAllocationAllocateWithdrawnRequest, CantonPrepareCommandResponse } from './iface'; +import { TransactionBuilder } from './transactionBuilder'; +import { Transaction } from './transaction/transaction'; +import utils from './utils'; + +export class AllocationAllocateWithdrawnBuilder extends TransactionBuilder { + private _commandId: string; + private _contractId: string; + private _actAsPartyId: string; + private _tokenName: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + } + + initBuilder(tx: Transaction): void { + super.initBuilder(tx); + this.setTransactionType(); + } + + get transactionType(): TransactionType { + return TransactionType.AllocationAllocateWithdrawn; + } + + setTransactionType(): void { + this.transaction.transactionType = TransactionType.AllocationAllocateWithdrawn; + } + + 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 id for the allocation allocate withdrawn + * 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(); + // also set the transaction _id + this.transaction.id = id.trim(); + return this; + } + + /** + * Sets the contract id of the allocation to withdraw + * @param id - canton allocation 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; + } + + /** + * The party withdrawing the allocation + * + * @param id - the sender party id + * @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; + } + + /** + * The token name for the allocation withdrawal + * @param name - the bitgo name of the asset + * @returns The current builder instance for chaining. + * @throws Error if name is empty. + */ + tokenName(name: string): this { + if (!name || !name.trim()) { + throw new Error('tokenName must be a non-empty string'); + } + this._tokenName = name.trim(); + return this; + } + + /** + * Builds and returns the CantonAllocationAllocateWithdrawnRequest object from the builder's internal state. + * + * This method performs validation before constructing the object. If required fields are + * missing or invalid, it throws an error. + * + * @returns {CantonAllocationAllocateWithdrawnRequest} - A fully constructed and validated request object for allocation allocate withdrawal. + * @throws {Error} If any required field is missing or fails validation. + */ + toRequestObject(): CantonAllocationAllocateWithdrawnRequest { + this.validate(); + + return { + commandId: this._commandId, + contractId: this._contractId, + verboseHashing: false, + actAs: [this._actAsPartyId], + readAs: [], + tokenName: this._tokenName, + }; + } + + /** + * 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/iface.ts b/modules/sdk-coin-canton/src/lib/iface.ts index 4a178b5d8c..08413d1b51 100644 --- a/modules/sdk-coin-canton/src/lib/iface.ts +++ b/modules/sdk-coin-canton/src/lib/iface.ts @@ -159,6 +159,10 @@ export interface CantonTransferOfferWithdrawnRequest extends CantonTransferAccep tokenName?: string; } +export interface CantonAllocationAllocateWithdrawnRequest extends CantonTransferAcceptRejectRequest { + tokenName?: string; +} + export interface TransferAcknowledge { contractId: string; senderPartyId: string; diff --git a/modules/sdk-coin-canton/src/lib/index.ts b/modules/sdk-coin-canton/src/lib/index.ts index 09edd390e9..2c0bffae5c 100644 --- a/modules/sdk-coin-canton/src/lib/index.ts +++ b/modules/sdk-coin-canton/src/lib/index.ts @@ -3,6 +3,7 @@ import * as Interface from './iface'; export { CantonCommandBuilder } from './cantonCommandBuilder'; export { AllocationAllocateBuilder } from './allocationAllocateBuilder'; +export { AllocationAllocateWithdrawnBuilder } from './allocationAllocateWithdrawnBuilder'; export { AllocationRequestBuilder } from './allocationRequestBuilder'; export { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder'; export { CosignDelegationProposalBuilder } from './cosignDelegationProposalBuilder'; diff --git a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts index b7baf904af..47cbbebcd4 100644 --- a/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts @@ -6,6 +6,7 @@ import { } from '@bitgo/sdk-core'; import { BaseCoin as CoinConfig } from '@bitgo/statics'; import { AllocationAllocateBuilder } from './allocationAllocateBuilder'; +import { AllocationAllocateWithdrawnBuilder } from './allocationAllocateWithdrawnBuilder'; import { AllocationRequestBuilder } from './allocationRequestBuilder'; import { CantonCommandBuilder } from './cantonCommandBuilder'; import { CosignDelegationAcceptBuilder } from './cosignDelegationAcceptBuilder'; @@ -62,6 +63,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { case TransactionType.AllocationAllocate: { return this.getAllocationAllocateBuilder(tx); } + case TransactionType.AllocationAllocateWithdrawn: { + return this.getAllocationAllocateWithdrawnBuilder(tx); + } case TransactionType.AllocationRequest: { return this.getAllocationRequestBuilder(tx); } @@ -79,6 +83,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return TransactionBuilderFactory.initializeBuilder(tx, new AllocationAllocateBuilder(this._coinConfig)); } + getAllocationAllocateWithdrawnBuilder(tx?: Transaction): AllocationAllocateWithdrawnBuilder { + return TransactionBuilderFactory.initializeBuilder(tx, new AllocationAllocateWithdrawnBuilder(this._coinConfig)); + } + getAllocationRequestBuilder(tx?: Transaction): AllocationRequestBuilder { return TransactionBuilderFactory.initializeBuilder(tx, new AllocationRequestBuilder(this._coinConfig)); } diff --git a/modules/sdk-coin-canton/src/lib/utils.ts b/modules/sdk-coin-canton/src/lib/utils.ts index 2b01c3f394..ed5dec3000 100644 --- a/modules/sdk-coin-canton/src/lib/utils.ts +++ b/modules/sdk-coin-canton/src/lib/utils.ts @@ -308,6 +308,30 @@ export class Utils implements BaseUtils { break; } + case TransactionType.AllocationAllocateWithdrawn: { + // Holding create node → owner=sender=receiver (allocating party gets their holding back), + // instrument.{source=admin, id}, amount + const holdingFields = findCreateNodeFields('Holding'); + if (holdingFields) { + const ownerData = getField(holdingFields, 'owner'); + if (ownerData?.oneofKind === 'party') { + receiver = ownerData.party ?? ''; + sender = receiver; + } + const amountData = getField(holdingFields, 'amount'); + if (amountData?.oneofKind === 'numeric') amount = amountData.numeric ?? ''; + const instrumentData = getField(holdingFields, 'instrument'); + if (instrumentData?.oneofKind === 'record') { + const instrumentFields = instrumentData.record?.fields ?? []; + const sourceData = getField(instrumentFields, 'source'); + if (sourceData?.oneofKind === 'party') instrumentAdmin = sourceData.party ?? ''; + const idData = getField(instrumentFields, 'id'); + if (idData?.oneofKind === 'text') instrumentId = idData.text ?? ''; + } + } + break; + } + case TransactionType.AllocationAllocate: { // DvpLegAllocation create node → allocation.transferLeg contains the full settlement transfer details: // sender, receiver (the actual settlement counterparty), amount, and instrumentId diff --git a/modules/sdk-coin-canton/test/unit/builder/allocationAllocateWithdrawn/allocationAllocateWithdrawnBuilder.ts b/modules/sdk-coin-canton/test/unit/builder/allocationAllocateWithdrawn/allocationAllocateWithdrawnBuilder.ts new file mode 100644 index 0000000000..c51c869e5d --- /dev/null +++ b/modules/sdk-coin-canton/test/unit/builder/allocationAllocateWithdrawn/allocationAllocateWithdrawnBuilder.ts @@ -0,0 +1,147 @@ +import assert from 'assert'; +import should from 'should'; + +import { coins } from '@bitgo/statics'; + +import { AllocationAllocateWithdrawnBuilder, Transaction } from '../../../../src'; +import { CantonAllocationAllocateWithdrawnRequest } from '../../../../src/lib/iface'; + +import { CantonAllocationWithdrawPrepareResponse, TransferAcceptance } from '../../../resources'; + +describe('AllocationAllocateWithdrawn Builder', () => { + it('should get the allocation allocate withdrawn request object', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.setTransaction(CantonAllocationWithdrawPrepareResponse); + const { commandId, contractId, partyId } = TransferAcceptance; + txBuilder.commandId(commandId).contractId(contractId).actAs(partyId); + const requestObj: CantonAllocationAllocateWithdrawnRequest = 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], partyId); + assert.equal(requestObj.verboseHashing, false); + assert.deepEqual(requestObj.readAs, []); + }); + + it('should include tokenName when set', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.setTransaction(CantonAllocationWithdrawPrepareResponse); + const { commandId, contractId, partyId } = TransferAcceptance; + txBuilder.commandId(commandId).contractId(contractId).actAs(partyId).tokenName('tcanton:test-token'); + const requestObj: CantonAllocationAllocateWithdrawnRequest = txBuilder.toRequestObject(); + assert.equal(requestObj.tokenName, 'tcanton:test-token'); + }); + + it('should set the transaction id to the commandId', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + const { commandId } = TransferAcceptance; + txBuilder.commandId(commandId); + assert.equal(tx.id, commandId); + }); + + it('should validate raw canton allocation allocate withdrawn transaction', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.setTransaction(CantonAllocationWithdrawPrepareResponse); + txBuilder.validateRawTransaction(CantonAllocationWithdrawPrepareResponse.preparedTransaction); + }); + + it('should validate the transaction', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + tx.prepareCommand = CantonAllocationWithdrawPrepareResponse; + txBuilder.initBuilder(tx); + txBuilder.setTransaction(CantonAllocationWithdrawPrepareResponse); + txBuilder.validateTransaction(tx); + }); + + it('should throw error on invalid raw transaction hash mismatch', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + const invalidPrepareResponse = { + ...CantonAllocationWithdrawPrepareResponse, + preparedTransactionHash: '+vlIXv6Vgd2ypPXD0mrdn7RlcSH4c2hCRj2/tXqqUVs=', + }; + txBuilder.setTransaction(invalidPrepareResponse); + try { + txBuilder.validateRawTransaction(invalidPrepareResponse.preparedTransaction); + } catch (e) { + assert.equal(e.message, 'invalid raw transaction, hash not matching'); + } + }); + + it('should throw if commandId is missing', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + const { contractId, partyId } = TransferAcceptance; + txBuilder.contractId(contractId).actAs(partyId); + assert.throws(() => txBuilder.toRequestObject(), /commandId is missing/); + }); + + it('should throw if contractId is missing', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + const { commandId, partyId } = TransferAcceptance; + txBuilder.commandId(commandId).actAs(partyId); + assert.throws(() => txBuilder.toRequestObject(), /contractId is missing/); + }); + + it('should throw if actAs is missing', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + const { commandId, contractId } = TransferAcceptance; + txBuilder.commandId(commandId).contractId(contractId); + assert.throws(() => txBuilder.toRequestObject(), /actAs partyId is missing/); + }); + + it('should throw if commandId is an empty string', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.commandId(''), /commandId must be a non-empty string/); + }); + + it('should throw if contractId is an empty string', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.contractId(''), /contractId must be a non-empty string/); + }); + + it('should throw if actAs is an empty string', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.actAs(''), /actAsPartyId must be a non-empty string/); + }); + + it('should throw if tokenName is an empty string', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + assert.throws(() => txBuilder.tokenName(''), /tokenName must be a non-empty string/); + }); + + describe('Prepared transaction parsing', () => { + it('should parse the allocation allocate withdrawn prepared transaction', function () { + const txBuilder = new AllocationAllocateWithdrawnBuilder(coins.get('tcanton')); + const tx = new Transaction(coins.get('tcanton')); + txBuilder.initBuilder(tx); + txBuilder.commandId(TransferAcceptance.commandId); + txBuilder.setTransaction(CantonAllocationWithdrawPrepareResponse); + const txData = txBuilder.transaction.toJson(); + should.exist(txData); + // owner of the returned Holding = sender = receiver + assert.equal( + txData.sender, + 'ravi-2-step-party::122092e7d33ac10c0f3d55976342f37555df05da5b742956d56a62ae2367769079d2' + ); + assert.equal(txData.sender, txData.receiver); + assert.equal(txData.amount, '50000000000'); + }); + }); +}); diff --git a/modules/sdk-core/src/account-lib/baseCoin/enum.ts b/modules/sdk-core/src/account-lib/baseCoin/enum.ts index f8c3111023..05248d5d74 100644 --- a/modules/sdk-core/src/account-lib/baseCoin/enum.ts +++ b/modules/sdk-core/src/account-lib/baseCoin/enum.ts @@ -103,6 +103,8 @@ export enum TransactionType { CosignDelegationAccept, // canton allocation allocate AllocationAllocate, + // canton allocation allocate withdrawn + AllocationAllocateWithdrawn, // canton allocation request (internal/dummy txRequest surfacing DvP trade details to the allocating party) AllocationRequest, // canton generic DAML command