Skip to content
Open
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
1 change: 1 addition & 0 deletions modules/sdk-coin-canton/src/canton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export class Canton extends BaseCoin {
case TransactionType.TransferReject:
case TransactionType.TransferAcknowledge:
case TransactionType.OneStepPreApproval:
case TransactionType.TransferOfferWithdrawn:
// There is no input for these type of transactions, so always return true.
return true;
case TransactionType.Send:
Expand Down
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 @@ -138,6 +138,10 @@ export interface CantonTransferAcceptRejectRequest extends CantonPrepareCommandR
contractId: string;
}

export interface CantonTransferOfferWithdrawnRequest 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 @@ -9,6 +9,7 @@ export { TransferAcknowledgeBuilder } from './transferAcknowledgeBuilder';
export { TransferBuilder } from './transferBuilder';
export { TransactionBuilder } from './transactionBuilder';
export { TransactionBuilderFactory } from './transactionBuilderFactory';
export { TransferOfferWithdrawnBuilder } from './transferOfferWithdrawnBuilder';
export { TransferRejectionBuilder } from './transferRejectionBuilder';
export { WalletInitBuilder } from './walletInitBuilder';
export { WalletInitTransaction } from './walletInitialization/walletInitTransaction';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TransferAcceptanceBuilder } from './transferAcceptanceBuilder';
import { TransferAcknowledgeBuilder } from './transferAcknowledgeBuilder';
import { TransactionBuilder } from './transactionBuilder';
import { TransferBuilder } from './transferBuilder';
import { TransferOfferWithdrawnBuilder } from './transferOfferWithdrawnBuilder';
import { TransferRejectionBuilder } from './transferRejectionBuilder';
import { Transaction } from './transaction/transaction';
import { WalletInitBuilder } from './walletInitBuilder';
Expand Down Expand Up @@ -41,6 +42,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
case TransactionType.TransferAcknowledge: {
return this.getTransferAcknowledgeBuilder(tx);
}
case TransactionType.TransferOfferWithdrawn: {
return this.getTransferOfferWithdrawnBuilder();
}
case TransactionType.TransferReject: {
return this.getTransferRejectBuilder(tx);
}
Expand All @@ -63,6 +67,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
return TransactionBuilderFactory.initializeBuilder(tx, new TransferAcknowledgeBuilder(this._coinConfig));
}

getTransferOfferWithdrawnBuilder(tx?: Transaction): TransferOfferWithdrawnBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new TransferOfferWithdrawnBuilder(this._coinConfig));
}

getTransferRejectBuilder(tx?: Transaction): TransferRejectionBuilder {
return TransactionBuilderFactory.initializeBuilder(tx, new TransferRejectionBuilder(this._coinConfig));
}
Expand Down
139 changes: 139 additions & 0 deletions modules/sdk-coin-canton/src/lib/transferOfferWithdrawnBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { InvalidTransactionError, PublicKey, TransactionType } from '@bitgo/sdk-core';
import { BaseCoin as CoinConfig } from '@bitgo/statics';
import { CantonPrepareCommandResponse, CantonTransferOfferWithdrawnRequest } from './iface';
import { TransactionBuilder } from './transactionBuilder';
import { Transaction } from './transaction/transaction';
import utils from './utils';

export class TransferOfferWithdrawnBuilder 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.TransferOfferWithdrawn;
}

setTransactionType(): void {
this.transaction.transactionType = TransactionType.TransferOfferWithdrawn;
}

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 transfer offer 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 the receiver needs to withdraw
* @param id - canton withdrawn 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 sender who wants to withdraw the offer
*
* @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 to withdraw the offer
* @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 CantonTransferOfferWithdrawnRequest 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 {CantonTransferOfferWithdrawnRequest} - A fully constructed and validated request object for transfer offer withdrawal.
* @throws {Error} If any required field is missing or fails validation.
*/
toRequestObject(): CantonTransferOfferWithdrawnRequest {
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('receiver partyId is missing');
}
}
56 changes: 56 additions & 0 deletions modules/sdk-coin-canton/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@
let transferNode: RecordField[] = [];
let transferAcceptRejectNode: RecordField[] = [];
let tokenTransferAcceptRejectNode: RecordField[] = [];
let withdrawnNode: RecordField[] = [];
let tokenWithdrawnNode: RecordField[] = [];
const nodes = decodedData.transaction?.nodes;

nodes?.forEach((node) => {
Expand Down Expand Up @@ -140,6 +142,20 @@
tokenTransferAcceptRejectNode = transferSum.record?.fields ?? [];
}
}
if (
template?.entityName === 'Amulet' &&
!withdrawnNode.length &&
txType === TransactionType.TransferOfferWithdrawn
) {
withdrawnNode = fields;
}
if (
template?.entityName === 'Holding' &&
!tokenWithdrawnNode.length &&
txType === TransactionType.TransferOfferWithdrawn
) {
tokenWithdrawnNode = fields;
}
});

nodes?.forEach((node) => {
Expand Down Expand Up @@ -247,6 +263,46 @@
instrumentId = idData.text ?? '';
}
}
} else if (withdrawnNode.length) {
const ownerData = getField(withdrawnNode, 'owner');
if (ownerData?.oneofKind === 'party') {
receiver = ownerData.party ?? '';
if (!sender) {

Check warning

Code scanning / CodeQL

Useless conditional Warning

This negation always evaluates to true.

Copilot Autofix

AI 8 minutes ago

In general, a "useless conditional" where a condition is always true or always false should be removed or rewritten so that it expresses an actual decision. Keeping such a condition is misleading and can hide bugs or confuse future readers.

Here, CodeQL indicates that !sender is always true at the point of if (!sender) { sender = receiver; }. That means that in all reachable executions of this branch, sender is falsy, so the body of the if always runs. The current effective behavior is: whenever we have a withdrawnNode (or tokenWithdrawnNode in the similar block), we set receiver to the owner party and then always set sender to the same value. To keep behavior unchanged but make the code honest and clear, we should remove the if (!sender) wrapper and directly assign sender = receiver; when we are in these branches and have a party owner.

Concretely, in modules/sdk-coin-canton/src/lib/utils.ts, in the withdrawnNode block around line 266, change:

if (ownerData?.oneofKind === 'party') {
  receiver = ownerData.party ?? '';
  if (!sender) {
    sender = receiver;
  }
}

to:

if (ownerData?.oneofKind === 'party') {
  receiver = ownerData.party ?? '';
  sender = receiver;
}

This preserves the runtime behavior implied by CodeQL’s analysis (since the inner if was always true) while removing the useless condition. The similar pattern exists in the tokenWithdrawnNode block around lines 283–288; to maintain consistency and avoid a similar useless-conditional warning there (and to reflect the same semantic “sender defaults to owner”), apply the same simplification: always set sender = receiver; inside that ownerData check. No new imports, methods, or definitions are required.

Suggested changeset 1
modules/sdk-coin-canton/src/lib/utils.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/modules/sdk-coin-canton/src/lib/utils.ts b/modules/sdk-coin-canton/src/lib/utils.ts
--- a/modules/sdk-coin-canton/src/lib/utils.ts
+++ b/modules/sdk-coin-canton/src/lib/utils.ts
@@ -267,9 +267,7 @@
       const ownerData = getField(withdrawnNode, 'owner');
       if (ownerData?.oneofKind === 'party') {
         receiver = ownerData.party ?? '';
-        if (!sender) {
-          sender = receiver;
-        }
+        sender = receiver;
       }
       const amountField = getField(withdrawnNode, 'amount');
       if (amountField?.oneofKind === 'record') {
@@ -283,9 +281,7 @@
       const ownerData = getField(tokenWithdrawnNode, 'owner');
       if (ownerData?.oneofKind === 'party') {
         receiver = ownerData.party ?? '';
-        if (!sender) {
-          sender = receiver;
-        }
+        sender = receiver;
       }
       const amountData = getField(tokenWithdrawnNode, 'amount');
       if (amountData?.oneofKind === 'numeric') {
EOF
@@ -267,9 +267,7 @@
const ownerData = getField(withdrawnNode, 'owner');
if (ownerData?.oneofKind === 'party') {
receiver = ownerData.party ?? '';
if (!sender) {
sender = receiver;
}
sender = receiver;
}
const amountField = getField(withdrawnNode, 'amount');
if (amountField?.oneofKind === 'record') {
@@ -283,9 +281,7 @@
const ownerData = getField(tokenWithdrawnNode, 'owner');
if (ownerData?.oneofKind === 'party') {
receiver = ownerData.party ?? '';
if (!sender) {
sender = receiver;
}
sender = receiver;
}
const amountData = getField(tokenWithdrawnNode, 'amount');
if (amountData?.oneofKind === 'numeric') {
Copilot is powered by AI and may make mistakes. Always verify output.
sender = receiver;
}
}
const amountField = getField(withdrawnNode, 'amount');
if (amountField?.oneofKind === 'record') {
const amountFields = amountField.record?.fields ?? [];
const initialAmountData = getField(amountFields, 'initialAmount');
if (initialAmountData?.oneofKind === 'numeric') {
amount = initialAmountData.numeric ?? '';
}
}
} else if (tokenWithdrawnNode.length) {
const ownerData = getField(tokenWithdrawnNode, 'owner');
if (ownerData?.oneofKind === 'party') {
receiver = ownerData.party ?? '';
if (!sender) {

Check warning

Code scanning / CodeQL

Useless conditional Warning

This negation always evaluates to true.

Copilot Autofix

AI 8 minutes ago

In general, a useless conditional where a boolean expression is always true or always false should either be removed or rewritten to check the correct, varying condition. Here, sender is always falsy on entry to the withdrawnNode and tokenWithdrawnNode branches, so if (!sender) { sender = receiver; } will always assign sender = receiver. The simplest fix that preserves behavior is to remove the conditional and perform the assignment unconditionally.

Concretely, in modules/sdk-coin-canton/src/lib/utils.ts, in both the withdrawnNode and tokenWithdrawnNode branches, replace:

if (!sender) {
  sender = receiver;
}

with a direct assignment:

sender = receiver;

This maintains the effective runtime behavior (since the condition was always true on these paths) while removing the useless conditional flagged by CodeQL. No new imports, methods, or additional definitions are required.

Suggested changeset 1
modules/sdk-coin-canton/src/lib/utils.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/modules/sdk-coin-canton/src/lib/utils.ts b/modules/sdk-coin-canton/src/lib/utils.ts
--- a/modules/sdk-coin-canton/src/lib/utils.ts
+++ b/modules/sdk-coin-canton/src/lib/utils.ts
@@ -267,9 +267,7 @@
       const ownerData = getField(withdrawnNode, 'owner');
       if (ownerData?.oneofKind === 'party') {
         receiver = ownerData.party ?? '';
-        if (!sender) {
-          sender = receiver;
-        }
+        sender = receiver;
       }
       const amountField = getField(withdrawnNode, 'amount');
       if (amountField?.oneofKind === 'record') {
@@ -283,9 +281,7 @@
       const ownerData = getField(tokenWithdrawnNode, 'owner');
       if (ownerData?.oneofKind === 'party') {
         receiver = ownerData.party ?? '';
-        if (!sender) {
-          sender = receiver;
-        }
+        sender = receiver;
       }
       const amountData = getField(tokenWithdrawnNode, 'amount');
       if (amountData?.oneofKind === 'numeric') {
EOF
@@ -267,9 +267,7 @@
const ownerData = getField(withdrawnNode, 'owner');
if (ownerData?.oneofKind === 'party') {
receiver = ownerData.party ?? '';
if (!sender) {
sender = receiver;
}
sender = receiver;
}
const amountField = getField(withdrawnNode, 'amount');
if (amountField?.oneofKind === 'record') {
@@ -283,9 +281,7 @@
const ownerData = getField(tokenWithdrawnNode, 'owner');
if (ownerData?.oneofKind === 'party') {
receiver = ownerData.party ?? '';
if (!sender) {
sender = receiver;
}
sender = receiver;
}
const amountData = getField(tokenWithdrawnNode, 'amount');
if (amountData?.oneofKind === 'numeric') {
Copilot is powered by AI and may make mistakes. Always verify output.
sender = receiver;
}
}
const amountData = getField(tokenWithdrawnNode, 'amount');
if (amountData?.oneofKind === 'numeric') {
amount = amountData.numeric ?? '';
}
const instrumentData = getField(tokenWithdrawnNode, 'instrument');
if (instrumentData?.oneofKind === 'record') {
const instrumentFields = instrumentData.record?.fields ?? [];
const adminData = getField(instrumentFields, 'source');
if (adminData?.oneofKind === 'party') {
instrumentAdmin = adminData.party ?? '';
}
const idData = getField(instrumentFields, 'id');
if (idData?.oneofKind === 'text') {
instrumentId = idData.text ?? '';
}
}
}
if (!sender || !receiver || !amount) {
const missingFields: string[] = [];
Expand Down
28 changes: 28 additions & 0 deletions modules/sdk-coin-canton/test/resources.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from 'assert';
import should from 'should';

import { coins } from '@bitgo/statics';

import { TransferAcceptanceBuilder, Transaction, TransferOfferWithdrawnBuilder } from '../../../../src';
import { CantonTransferAcceptRejectRequest } from '../../../../src/lib/iface';

import {
CantonTokenTransferOfferWithdrawnPrepareResponse,
CantonTransferOfferWithdrawnPrepareResponse,
TransferAcceptance,
} from '../../../resources';

describe('Transfer Offer Withdrawn Builder', () => {
it('should get the transfer offer withdrawn request object', function () {
const txBuilder = new TransferOfferWithdrawnBuilder(coins.get('tcanton'));
const transferOfferWithdrawnTx = new Transaction(coins.get('tcanton'));
txBuilder.initBuilder(transferOfferWithdrawnTx);
txBuilder.setTransaction(CantonTransferOfferWithdrawnPrepareResponse);
const { commandId, contractId, partyId } = TransferAcceptance;
txBuilder.commandId(commandId).contractId(contractId).actAs(partyId);
const requestObj: CantonTransferAcceptRejectRequest = txBuilder.toRequestObject();
should.exist(requestObj);
assert.equal(requestObj.commandId, commandId);
assert.equal(requestObj.contractId, contractId);
assert.equal(requestObj.actAs.length, 1);
const actAs = requestObj.actAs[0];
assert.equal(actAs, partyId);
});

it('should validate raw canton transfer offer withdrawn transaction', function () {
const txBuilder = new TransferAcceptanceBuilder(coins.get('tcanton'));
const transferOfferWithdrawnTx = new Transaction(coins.get('tcanton'));
txBuilder.initBuilder(transferOfferWithdrawnTx);
txBuilder.setTransaction(CantonTransferOfferWithdrawnPrepareResponse);
txBuilder.validateRawTransaction(CantonTransferOfferWithdrawnPrepareResponse.preparedTransaction);
});

it('should validate raw canton token transfer offer withdrawn transaction', function () {
const txBuilder = new TransferAcceptanceBuilder(coins.get('tcanton'));
const transferOfferWithdrawnTx = new Transaction(coins.get('tcanton'));
txBuilder.initBuilder(transferOfferWithdrawnTx);
txBuilder.setTransaction(CantonTokenTransferOfferWithdrawnPrepareResponse);
txBuilder.validateRawTransaction(CantonTokenTransferOfferWithdrawnPrepareResponse.preparedTransaction);
});

it('should validate the transaction', function () {
const txBuilder = new TransferAcceptanceBuilder(coins.get('tcanton'));
const transferOfferWithdrawnTx = new Transaction(coins.get('tcanton'));
transferOfferWithdrawnTx.prepareCommand = CantonTransferOfferWithdrawnPrepareResponse;
txBuilder.initBuilder(transferOfferWithdrawnTx);
txBuilder.setTransaction(CantonTransferOfferWithdrawnPrepareResponse);
txBuilder.validateTransaction(transferOfferWithdrawnTx);
});

it('should throw error in validating raw transaction', function () {
const txBuilder = new TransferAcceptanceBuilder(coins.get('tcanton'));
const transferOfferWithdrawnTx = new Transaction(coins.get('tcanton'));
txBuilder.initBuilder(transferOfferWithdrawnTx);
const invalidPrepareResponse = CantonTransferOfferWithdrawnPrepareResponse;
invalidPrepareResponse.preparedTransactionHash = '+vlIXv6Vgd2ypPXD0mrdn7RlcSH4c2hCRj2/tXqqUVs=';
txBuilder.setTransaction(invalidPrepareResponse);
try {
txBuilder.validateRawTransaction(invalidPrepareResponse.preparedTransaction);
} catch (e) {
assert.equal(e.message, 'invalid raw transaction, hash not matching');
}
});
});
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 @@ -95,6 +95,8 @@ export enum TransactionType {
TransferAcknowledge,
// canton transfer reject, 2-step
TransferReject,
// canton transfer offer withdrawn, 2-step
TransferOfferWithdrawn,

// trx
FREEZE,
Expand Down