Skip to content

Commit 0cf58c3

Browse files
authored
Merge pull request #8052 from BitGo/COIN-7303
feat: added optional token, verify & explain token transaction
2 parents e07fe2a + 225baec commit 0cf58c3

7 files changed

Lines changed: 155 additions & 12 deletions

File tree

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

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,23 @@ export class Canton extends BaseCoin {
118118
case TransactionType.Send:
119119
if (txParams.recipients !== undefined) {
120120
const filteredRecipients = txParams.recipients?.map((recipient) => {
121-
const { address, amount } = recipient;
121+
const { address, amount, tokenName } = recipient;
122122
const [addressPart, memoId] = address.split('?memoId=');
123-
if (memoId) {
124-
return { address: addressPart, amount, memo: memoId };
125-
}
126-
return { address, amount };
123+
return {
124+
address: addressPart,
125+
amount,
126+
...(memoId && { memo: memoId }),
127+
...(tokenName && { tokenName }),
128+
};
127129
});
128130
const filteredOutputs = explainedTx.outputs?.map((output) => {
129-
const { address, amount, memo } = output;
130-
if (memo) {
131-
return { address, amount, memo };
132-
}
133-
return { address, amount };
131+
const { address, amount, tokenName, memo } = output;
132+
return {
133+
address,
134+
amount,
135+
...(memo && { memo }),
136+
...(tokenName && { tokenName }),
137+
};
134138
});
135139
if (JSON.stringify(filteredRecipients) !== JSON.stringify(filteredOutputs)) {
136140
throw new Error('Tx outputs do not match with expected txParams recipients');

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ export interface TxData {
2222
amount: string;
2323
acknowledgeData?: TransferAcknowledge;
2424
memoId?: string;
25+
token?: string;
2526
}
2627

2728
export interface PreparedTxnParsedInfo {
2829
sender: string;
2930
receiver: string;
3031
amount: string;
3132
memoId?: string;
33+
token?: string;
3234
}
3335

3436
export interface WalletInitTxData {
@@ -152,4 +154,5 @@ export interface CantonTransferRequest {
152154
expiryEpoch: number;
153155
sendViaOneStep: boolean;
154156
memoId?: string;
157+
token?: string;
155158
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ export class Transaction extends BaseTransaction {
160160
if (parsedInfo.memoId) {
161161
result.memoId = parsedInfo.memoId;
162162
}
163+
if (parsedInfo.token) {
164+
result.token = parsedInfo.token;
165+
}
163166
return result;
164167
}
165168

@@ -208,12 +211,12 @@ export class Transaction extends BaseTransaction {
208211
const input: Entry = {
209212
address: txData.sender,
210213
value: txData.amount,
211-
coin: this._coinConfig.name,
214+
coin: txData.token ? txData.token : this._coinConfig.name,
212215
};
213216
const output: Entry = {
214217
address: txData.receiver,
215218
value: txData.amount,
216-
coin: this._coinConfig.name,
219+
coin: txData.token ? txData.token : this._coinConfig.name,
217220
};
218221
inputs.push(input);
219222
outputs.push(output);
@@ -254,6 +257,9 @@ export class Transaction extends BaseTransaction {
254257
if (txData.memoId) {
255258
output.memo = txData.memoId;
256259
}
260+
if (txData.token) {
261+
output.tokenName = txData.token;
262+
}
257263
outputs.push(output);
258264
outputAmount = txData.amount;
259265
break;

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export class TransferBuilder extends TransactionBuilder {
1313
private _sendOneStep = false;
1414
private _expiryEpoch: number;
1515
private _memoId: string;
16+
private _token: string;
1617
constructor(_coinConfig: Readonly<CoinConfig>) {
1718
super(_coinConfig);
1819
}
@@ -145,6 +146,20 @@ export class TransferBuilder extends TransactionBuilder {
145146
return this;
146147
}
147148

149+
/**
150+
* Sets the optional token field if present, used for canton token transaction
151+
* @param name - the bitgo name of the token
152+
* @returns The current builder for chaining
153+
* @throws Error if name is invalid
154+
*/
155+
token(name: string): this {
156+
if (!name || !name.trim()) {
157+
throw new Error('token name must be a non-empty string');
158+
}
159+
this._token = name.trim();
160+
return this;
161+
}
162+
148163
/**
149164
* Get the canton transfer request object
150165
* @returns CantonTransferRequest
@@ -163,6 +178,9 @@ export class TransferBuilder extends TransactionBuilder {
163178
if (this._memoId) {
164179
data.memoId = this._memoId;
165180
}
181+
if (this._token) {
182+
data.token = this._token;
183+
}
166184
return data;
167185
}
168186

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import BigNumber from 'bignumber.js';
22
import crypto from 'crypto';
33

44
import { BaseUtils, isValidEd25519PublicKey, TransactionType } from '@bitgo/sdk-core';
5+
import { coins, CantonToken } from '@bitgo/statics';
56

67
import { computePreparedTransaction } from '../../resources/hash/hash.js';
78
import { PreparedTransaction } from '../../resources/proto/preparedTransaction.js';
@@ -93,6 +94,9 @@ export class Utils implements BaseUtils {
9394
let receiver = '';
9495
let amount = '';
9596
let memoId: string | undefined;
97+
let instrumentId: string | undefined;
98+
let instrumentAdmin: string | undefined;
99+
let token: string | undefined;
96100
let preApprovalNode: RecordField[] = [];
97101
let transferNode: RecordField[] = [];
98102
let transferAcceptRejectNode: RecordField[] = [];
@@ -165,6 +169,21 @@ export class Utils implements BaseUtils {
165169
const amountData = getField(transferRecord, 'amount');
166170
if (amountData?.oneofKind === 'numeric') amount = amountData.numeric ?? '';
167171

172+
const instrumentField = getField(transferRecord, 'instrumentId');
173+
if (instrumentField?.oneofKind === 'record') {
174+
const instrumentFields = instrumentField.record?.fields ?? [];
175+
176+
const adminData = getField(instrumentFields, 'admin');
177+
if (adminData?.oneofKind === 'party') {
178+
instrumentAdmin = adminData.party ?? '';
179+
}
180+
181+
const idData = getField(instrumentFields, 'id');
182+
if (idData?.oneofKind === 'text') {
183+
instrumentId = idData.text ?? '';
184+
}
185+
}
186+
168187
const metaField = getField(transferRecord, 'meta');
169188
if (metaField?.oneofKind === 'record') {
170189
const metaFields = metaField.record?.fields;
@@ -214,6 +233,10 @@ export class Utils implements BaseUtils {
214233
if (memoId) {
215234
parsedData.memoId = memoId;
216235
}
236+
if (instrumentId && instrumentAdmin) {
237+
token = this.findTokenNameByContractAddress(`${instrumentAdmin}:${instrumentId}`);
238+
parsedData.token = token;
239+
}
217240
return parsedData;
218241
}
219242

@@ -371,6 +394,21 @@ export class Utils implements BaseUtils {
371394
private convertAmountToLowestUnit(value: BigNumber): string {
372395
return value.multipliedBy(new BigNumber(10).pow(10)).toFixed(0);
373396
}
397+
398+
/**
399+
* Get the bitgo token name using the on-chain instrument details
400+
* @param contractAddress - the contract address of the form, `instrumentAdmin:instrumentId`
401+
* @returns tokenName if contractAddress matches with any supported canton tokens
402+
*/
403+
private findTokenNameByContractAddress(contractAddress: string): string | undefined {
404+
if (contractAddress.includes('Amulet')) {
405+
return undefined;
406+
}
407+
const cantonToken = coins
408+
.filter((coin) => coin instanceof CantonToken && coin.contractAddress === contractAddress)
409+
.map((coin) => coin as CantonToken);
410+
return cantonToken ? cantonToken[0].name : undefined;
411+
}
374412
}
375413

376414
const utils = new Utils();
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { BitGoAPI } from '@bitgo/sdk-api';
2+
import { ITransactionRecipient, Wallet } from '@bitgo/sdk-core';
3+
import { TestBitGo, TestBitGoAPI } from '@bitgo/sdk-test';
4+
5+
import { CantonTokenTransferRawTxn, TokenTxParams } from '../resources';
6+
import { CantonToken } from '../../src';
7+
8+
describe('Canton Token integration tests', function () {
9+
const tokenName = 'tcanton:testtoken';
10+
let bitgo: TestBitGoAPI;
11+
let basecoin: CantonToken;
12+
let newTxPrebuild: () => { txHex: string; txInfo: Record<string, unknown> };
13+
let newTxParams: () => { recipients: ITransactionRecipient[] };
14+
let wallet: Wallet;
15+
const txPrebuild = {
16+
txHex: CantonTokenTransferRawTxn,
17+
txInfo: {},
18+
};
19+
const txParams = {
20+
recipients: [
21+
{
22+
address: TokenTxParams.RECIPIENT_ADDRESS,
23+
amount: TokenTxParams.AMOUNT,
24+
tokenName: TokenTxParams.TOKEN,
25+
},
26+
],
27+
};
28+
before(() => {
29+
bitgo = TestBitGo.decorate(BitGoAPI, { env: 'mock' });
30+
CantonToken.createTokenConstructors().forEach(({ name, coinConstructor }) => {
31+
bitgo.safeRegister(name, coinConstructor);
32+
});
33+
basecoin = bitgo.coin(tokenName) as CantonToken;
34+
newTxPrebuild = () => {
35+
return structuredClone(txPrebuild);
36+
};
37+
newTxParams = () => {
38+
return structuredClone(txParams);
39+
};
40+
wallet = new Wallet(bitgo, basecoin, {});
41+
});
42+
43+
describe('Verify Transaction', function () {
44+
it('should verify token transfer transaction', async function () {
45+
const txPrebuild = newTxPrebuild();
46+
const txParams = newTxParams();
47+
const isTxnVerifies = await basecoin.verifyTransaction({ txPrebuild: txPrebuild, txParams: txParams, wallet });
48+
isTxnVerifies.should.equal(true);
49+
});
50+
});
51+
});

modules/sdk-coin-canton/test/resources.ts

Lines changed: 23 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)