Skip to content

Commit 4a7d819

Browse files
authored
Merge pull request #8050 from BitGo/COIN-7486
feat: added canton offer withdrawn builder
2 parents 44f6e39 + 325e4f3 commit 4a7d819

12 files changed

Lines changed: 406 additions & 1 deletion

File tree

modules/sdk-coin-canton/src/canton.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export class Canton extends BaseCoin {
113113
case TransactionType.TransferReject:
114114
case TransactionType.TransferAcknowledge:
115115
case TransactionType.OneStepPreApproval:
116+
case TransactionType.TransferOfferWithdrawn:
116117
// There is no input for these type of transactions, so always return true.
117118
return true;
118119
case TransactionType.Send:

modules/sdk-coin-canton/src/lib/iface.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,17 @@ export interface TransactionBroadcastData {
132132

133133
export interface CantonOneStepEnablementRequest extends CantonPrepareCommandRequest {
134134
receiverId: string;
135+
token?: string;
135136
}
136137

137138
export interface CantonTransferAcceptRejectRequest extends CantonPrepareCommandRequest {
138139
contractId: string;
139140
}
140141

142+
export interface CantonTransferOfferWithdrawnRequest extends CantonTransferAcceptRejectRequest {
143+
tokenName?: string;
144+
}
145+
141146
export interface TransferAcknowledge {
142147
contractId: string;
143148
senderPartyId: string;

modules/sdk-coin-canton/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { TransferAcknowledgeBuilder } from './transferAcknowledgeBuilder';
99
export { TransferBuilder } from './transferBuilder';
1010
export { TransactionBuilder } from './transactionBuilder';
1111
export { TransactionBuilderFactory } from './transactionBuilderFactory';
12+
export { TransferOfferWithdrawnBuilder } from './transferOfferWithdrawnBuilder';
1213
export { TransferRejectionBuilder } from './transferRejectionBuilder';
1314
export { WalletInitBuilder } from './walletInitBuilder';
1415
export { WalletInitTransaction } from './walletInitialization/walletInitTransaction';

modules/sdk-coin-canton/src/lib/oneStepPreApprovalBuilder.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import utils from './utils';
88
export class OneStepPreApprovalBuilder extends TransactionBuilder {
99
private _commandId: string;
1010
private _receiverPartyId: string;
11+
private _token: string;
1112
constructor(_coinConfig: Readonly<CoinConfig>) {
1213
super(_coinConfig);
1314
}
@@ -73,6 +74,20 @@ export class OneStepPreApprovalBuilder extends TransactionBuilder {
7374
return this;
7475
}
7576

77+
/**
78+
* Sets the optional token field if present, used for canton token preApproval setup
79+
* @param name - the bitgo name of the token
80+
* @returns The current builder for chaining
81+
* @throws Error if name is invalid
82+
*/
83+
token(name: string): this {
84+
if (!name || !name.trim()) {
85+
throw new Error('token name must be a non-empty string');
86+
}
87+
this._token = name.trim();
88+
return this;
89+
}
90+
7691
/**
7792
* Builds and returns the CantonOneStepEnablementRequest object from the builder's internal state.
7893
*
@@ -91,6 +106,7 @@ export class OneStepPreApprovalBuilder extends TransactionBuilder {
91106
verboseHashing: false,
92107
actAs: [this._receiverPartyId],
93108
readAs: [],
109+
token: this._token,
94110
};
95111
}
96112

modules/sdk-coin-canton/src/lib/transaction/transaction.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,14 @@ export class Transaction extends BaseTransaction {
244244
case TransactionType.TransferAccept:
245245
case TransactionType.TransferReject: {
246246
const txData = this.toJson();
247-
inputs.push({ address: txData.sender, amount: txData.amount });
247+
const input: ITransactionRecipient = {
248+
address: txData.sender,
249+
amount: txData.amount,
250+
};
251+
if (txData.token) {
252+
input.tokenName = txData.token;
253+
}
254+
inputs.push(input);
248255
inputAmount = txData.amount;
249256
break;
250257
}
@@ -264,6 +271,19 @@ export class Transaction extends BaseTransaction {
264271
outputAmount = txData.amount;
265272
break;
266273
}
274+
case TransactionType.TransferOfferWithdrawn: {
275+
const txData = this.toJson();
276+
const input: ITransactionRecipient = {
277+
address: txData.receiver,
278+
amount: txData.amount,
279+
};
280+
if (txData.token) {
281+
input.tokenName = txData.token;
282+
}
283+
inputs.push(input);
284+
inputAmount = txData.amount;
285+
break;
286+
}
267287
}
268288
return {
269289
id: this.id,

modules/sdk-coin-canton/src/lib/transactionBuilderFactory.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TransferAcceptanceBuilder } from './transferAcceptanceBuilder';
1010
import { TransferAcknowledgeBuilder } from './transferAcknowledgeBuilder';
1111
import { TransactionBuilder } from './transactionBuilder';
1212
import { TransferBuilder } from './transferBuilder';
13+
import { TransferOfferWithdrawnBuilder } from './transferOfferWithdrawnBuilder';
1314
import { TransferRejectionBuilder } from './transferRejectionBuilder';
1415
import { Transaction } from './transaction/transaction';
1516
import { WalletInitBuilder } from './walletInitBuilder';
@@ -41,6 +42,9 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
4142
case TransactionType.TransferAcknowledge: {
4243
return this.getTransferAcknowledgeBuilder(tx);
4344
}
45+
case TransactionType.TransferOfferWithdrawn: {
46+
return this.getTransferOfferWithdrawnBuilder(tx);
47+
}
4448
case TransactionType.TransferReject: {
4549
return this.getTransferRejectBuilder(tx);
4650
}
@@ -63,6 +67,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
6367
return TransactionBuilderFactory.initializeBuilder(tx, new TransferAcknowledgeBuilder(this._coinConfig));
6468
}
6569

70+
getTransferOfferWithdrawnBuilder(tx?: Transaction): TransferOfferWithdrawnBuilder {
71+
return TransactionBuilderFactory.initializeBuilder(tx, new TransferOfferWithdrawnBuilder(this._coinConfig));
72+
}
73+
6674
getTransferRejectBuilder(tx?: Transaction): TransferRejectionBuilder {
6775
return TransactionBuilderFactory.initializeBuilder(tx, new TransferRejectionBuilder(this._coinConfig));
6876
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { InvalidTransactionError, PublicKey, TransactionType } from '@bitgo/sdk-core';
2+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
3+
import { CantonPrepareCommandResponse, CantonTransferOfferWithdrawnRequest } from './iface';
4+
import { TransactionBuilder } from './transactionBuilder';
5+
import { Transaction } from './transaction/transaction';
6+
import utils from './utils';
7+
8+
export class TransferOfferWithdrawnBuilder extends TransactionBuilder {
9+
private _commandId: string;
10+
private _contractId: string;
11+
private _actAsPartyId: string;
12+
private _tokenName: string;
13+
constructor(_coinConfig: Readonly<CoinConfig>) {
14+
super(_coinConfig);
15+
}
16+
17+
initBuilder(tx: Transaction): void {
18+
super.initBuilder(tx);
19+
this.setTransactionType();
20+
}
21+
22+
get transactionType(): TransactionType {
23+
return TransactionType.TransferOfferWithdrawn;
24+
}
25+
26+
setTransactionType(): void {
27+
this.transaction.transactionType = TransactionType.TransferOfferWithdrawn;
28+
}
29+
30+
setTransaction(transaction: CantonPrepareCommandResponse): void {
31+
this.transaction.prepareCommand = transaction;
32+
}
33+
34+
/** @inheritDoc */
35+
addSignature(publicKey: PublicKey, signature: Buffer): void {
36+
if (!this.transaction) {
37+
throw new InvalidTransactionError('transaction is empty!');
38+
}
39+
this._signatures.push({ publicKey, signature });
40+
const pubKeyBase64 = utils.getBase64FromHex(publicKey.pub);
41+
this.transaction.signerFingerprint = utils.getAddressFromPublicKey(pubKeyBase64);
42+
this.transaction.signatures = signature.toString('base64');
43+
}
44+
45+
/**
46+
* Sets the unique id for the transfer offer withdrawn
47+
* Also sets the _id of the transaction
48+
*
49+
* @param id - A uuid
50+
* @returns The current builder instance for chaining.
51+
* @throws Error if id is empty.
52+
*/
53+
commandId(id: string): this {
54+
if (!id || !id.trim()) {
55+
throw new Error('commandId must be a non-empty string');
56+
}
57+
this._commandId = id.trim();
58+
// also set the transaction _id
59+
this.transaction.id = id.trim();
60+
return this;
61+
}
62+
63+
/**
64+
* Sets the contract id the receiver needs to withdraw
65+
* @param id - canton withdrawn contract id
66+
* @returns The current builder instance for chaining.
67+
* @throws Error if id is empty.
68+
*/
69+
contractId(id: string): this {
70+
if (!id || !id.trim()) {
71+
throw new Error('contractId must be a non-empty string');
72+
}
73+
this._contractId = id.trim();
74+
return this;
75+
}
76+
77+
/**
78+
* The sender who wants to withdraw the offer
79+
*
80+
* @param id - the sender party id
81+
* @returns The current builder instance for chaining.
82+
* @throws Error if id is empty.
83+
*/
84+
actAs(id: string): this {
85+
if (!id || !id.trim()) {
86+
throw new Error('actAsPartyId must be a non-empty string');
87+
}
88+
this._actAsPartyId = id.trim();
89+
return this;
90+
}
91+
92+
/**
93+
* The token name to withdraw the offer
94+
* @param name - the bitgo name of the asset
95+
* @returns The current builder instance for chaining.
96+
* @throws Error if name is empty.
97+
*/
98+
tokenName(name: string): this {
99+
if (!name || !name.trim()) {
100+
throw new Error('tokenName must be a non-empty string');
101+
}
102+
this._tokenName = name.trim();
103+
return this;
104+
}
105+
106+
/**
107+
* Builds and returns the CantonTransferOfferWithdrawnRequest object from the builder's internal state.
108+
*
109+
* This method performs validation before constructing the object. If required fields are
110+
* missing or invalid, it throws an error.
111+
*
112+
* @returns {CantonTransferOfferWithdrawnRequest} - A fully constructed and validated request object for transfer offer withdrawal.
113+
* @throws {Error} If any required field is missing or fails validation.
114+
*/
115+
toRequestObject(): CantonTransferOfferWithdrawnRequest {
116+
this.validate();
117+
118+
return {
119+
commandId: this._commandId,
120+
contractId: this._contractId,
121+
verboseHashing: false,
122+
actAs: [this._actAsPartyId],
123+
readAs: [],
124+
tokenName: this._tokenName,
125+
};
126+
}
127+
128+
/**
129+
* Validates the internal state of the builder before building the request object.
130+
*
131+
* @private
132+
* @throws {Error} If any required field is missing or invalid.
133+
*/
134+
private validate(): void {
135+
if (!this._commandId) throw new Error('commandId is missing');
136+
if (!this._contractId) throw new Error('contractId is missing');
137+
if (!this._actAsPartyId) throw new Error('receiver partyId is missing');
138+
}
139+
}

modules/sdk-coin-canton/src/lib/utils.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,12 @@ export class Utils implements BaseUtils {
9898
let instrumentAdmin: string | undefined;
9999
let token: string | undefined;
100100
let preApprovalNode: RecordField[] = [];
101+
let tokenPreApprovalNode: RecordField[] = [];
101102
let transferNode: RecordField[] = [];
102103
let transferAcceptRejectNode: RecordField[] = [];
103104
let tokenTransferAcceptRejectNode: RecordField[] = [];
105+
let withdrawnNode: RecordField[] = [];
106+
let tokenWithdrawnNode: RecordField[] = [];
104107
const nodes = decodedData.transaction?.nodes;
105108

106109
nodes?.forEach((node) => {
@@ -122,6 +125,13 @@ export class Utils implements BaseUtils {
122125
) {
123126
preApprovalNode = fields;
124127
}
128+
if (
129+
template?.entityName === 'TransferPreapproval' &&
130+
!tokenPreApprovalNode.length &&
131+
txType === TransactionType.OneStepPreApproval
132+
) {
133+
tokenPreApprovalNode = fields;
134+
}
125135
if (
126136
template?.entityName === 'Amulet' &&
127137
!transferAcceptRejectNode.length &&
@@ -140,6 +150,20 @@ export class Utils implements BaseUtils {
140150
tokenTransferAcceptRejectNode = transferSum.record?.fields ?? [];
141151
}
142152
}
153+
if (
154+
template?.entityName === 'Amulet' &&
155+
!withdrawnNode.length &&
156+
txType === TransactionType.TransferOfferWithdrawn
157+
) {
158+
withdrawnNode = fields;
159+
}
160+
if (
161+
template?.entityName === 'Holding' &&
162+
!tokenWithdrawnNode.length &&
163+
txType === TransactionType.TransferOfferWithdrawn
164+
) {
165+
tokenWithdrawnNode = fields;
166+
}
143167
});
144168

145169
nodes?.forEach((node) => {
@@ -166,6 +190,27 @@ export class Utils implements BaseUtils {
166190
const providerData = getField(preApprovalNode, 'provider');
167191
if (providerData?.oneofKind === 'party') sender = providerData.party ?? '';
168192
amount = '0';
193+
} else if (tokenPreApprovalNode.length) {
194+
const receiverData = getField(tokenPreApprovalNode, 'receiver');
195+
if (receiverData?.oneofKind === 'party') receiver = receiverData.party ?? '';
196+
const operatorData = getField(tokenPreApprovalNode, 'operator');
197+
if (operatorData?.oneofKind === 'party') sender = operatorData.party ?? '';
198+
amount = '0';
199+
const instrumentAdminData = getField(tokenPreApprovalNode, 'instrumentAdmin');
200+
if (instrumentAdminData?.oneofKind === 'party') instrumentAdmin = instrumentAdminData.party ?? '';
201+
const allowancesData = getField(tokenPreApprovalNode, 'instrumentAllowances');
202+
if (allowancesData?.oneofKind === 'list') {
203+
// for the same instrument admin, if multiple tokens are supported then we can enable all of them,
204+
// but we won't be doing that for now
205+
const firstAllowance = allowancesData.list?.elements?.[0]?.sum;
206+
if (firstAllowance?.oneofKind === 'record') {
207+
const allowanceFields = firstAllowance.record?.fields ?? [];
208+
const idData = getField(allowanceFields, 'id');
209+
if (idData?.oneofKind === 'text') {
210+
instrumentId = idData.text ?? '';
211+
}
212+
}
213+
}
169214
} else if (transferNode.length) {
170215
const transferField = transferNode.find((f) => f.label === 'transfer');
171216
const transferSum = transferField?.value?.sum;
@@ -247,6 +292,42 @@ export class Utils implements BaseUtils {
247292
instrumentId = idData.text ?? '';
248293
}
249294
}
295+
} else if (withdrawnNode.length) {
296+
const ownerData = getField(withdrawnNode, 'owner');
297+
if (ownerData?.oneofKind === 'party') {
298+
receiver = ownerData.party ?? '';
299+
sender = receiver;
300+
}
301+
const amountField = getField(withdrawnNode, 'amount');
302+
if (amountField?.oneofKind === 'record') {
303+
const amountFields = amountField.record?.fields ?? [];
304+
const initialAmountData = getField(amountFields, 'initialAmount');
305+
if (initialAmountData?.oneofKind === 'numeric') {
306+
amount = initialAmountData.numeric ?? '';
307+
}
308+
}
309+
} else if (tokenWithdrawnNode.length) {
310+
const ownerData = getField(tokenWithdrawnNode, 'owner');
311+
if (ownerData?.oneofKind === 'party') {
312+
receiver = ownerData.party ?? '';
313+
sender = receiver;
314+
}
315+
const amountData = getField(tokenWithdrawnNode, 'amount');
316+
if (amountData?.oneofKind === 'numeric') {
317+
amount = amountData.numeric ?? '';
318+
}
319+
const instrumentData = getField(tokenWithdrawnNode, 'instrument');
320+
if (instrumentData?.oneofKind === 'record') {
321+
const instrumentFields = instrumentData.record?.fields ?? [];
322+
const adminData = getField(instrumentFields, 'source');
323+
if (adminData?.oneofKind === 'party') {
324+
instrumentAdmin = adminData.party ?? '';
325+
}
326+
const idData = getField(instrumentFields, 'id');
327+
if (idData?.oneofKind === 'text') {
328+
instrumentId = idData.text ?? '';
329+
}
330+
}
250331
}
251332
if (!sender || !receiver || !amount) {
252333
const missingFields: string[] = [];

0 commit comments

Comments
 (0)