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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<CoinConfig>) {
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');
}
}
4 changes: 4 additions & 0 deletions modules/sdk-coin-canton/src/lib/iface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-coin-canton/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand All @@ -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));
}
Expand Down
24 changes: 24 additions & 0 deletions modules/sdk-coin-canton/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
2 changes: 2 additions & 0 deletions modules/sdk-core/src/account-lib/baseCoin/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading