Skip to content

Commit 88a577a

Browse files
authored
Merge pull request #6791 from BitGo/coin-5343
feat(sdk-coin-vet): add vet nft tx builder
2 parents dba7cf5 + 131f81c commit 88a577a

File tree

10 files changed

+593
-1
lines changed

10 files changed

+593
-1
lines changed

modules/sdk-coin-vet/src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const TRANSFER_TOKEN_METHOD_ID = '0xa9059cbb';
66
export const STAKING_METHOD_ID = '0xa694fc3a';
77
export const EXIT_DELEGATION_METHOD_ID = '0x32b7006d';
88
export const BURN_NFT_METHOD_ID = '0x42966c68';
9+
export const TRANSFER_NFT_METHOD_ID = '0x23b872dd';
910

1011
export const STARGATE_NFT_ADDRESS = '0x1856c533ac2d94340aaa8544d35a5c1d4a21dee7';
1112
export const STARGATE_DELEGATION_ADDRESS = '0x4cb1c9ef05b529c093371264fab2c93cc6cddb0e';

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface VetTransactionData {
6161
tokenId?: string; // Added for unstaking and burn NFT transactions
6262
stakingContractAddress?: string;
6363
amountToStake?: string;
64+
nftCollectionId?: string;
6465
}
6566

6667
export interface VetTransactionExplanation extends BaseTransactionExplanation {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ export { AddressInitializationTransaction } from './transaction/addressInitializ
88
export { FlushTokenTransaction } from './transaction/flushTokenTransaction';
99
export { TokenTransaction } from './transaction/tokenTransaction';
1010
export { StakingTransaction } from './transaction/stakingTransaction';
11+
export { NFTTransaction } from './transaction/nftTransaction';
1112
export { TransactionBuilder } from './transactionBuilder/transactionBuilder';
1213
export { TransferBuilder } from './transactionBuilder/transferBuilder';
1314
export { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder';
1415
export { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder';
1516
export { StakingBuilder } from './transactionBuilder/stakingBuilder';
17+
export { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilder';
1618
export { TransactionBuilderFactory } from './transactionBuilderFactory';
1719
export { Constants, Utils, Interface };
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import assert from 'assert';
2+
import { Secp256k1, Transaction as VetTransaction } from '@vechain/sdk-core';
3+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
4+
import { InvalidTransactionError, TransactionType } from '@bitgo/sdk-core';
5+
import utils from '../utils';
6+
import { VetTransactionData } from '../iface';
7+
import { Transaction } from './transaction';
8+
9+
export class NFTTransaction extends Transaction {
10+
private _nftCollectionId: string;
11+
private _tokenId: string;
12+
13+
constructor(_coinConfig: Readonly<CoinConfig>) {
14+
super(_coinConfig);
15+
this._type = TransactionType.SendNFT;
16+
}
17+
18+
get nftCollectionId(): string {
19+
return this._nftCollectionId;
20+
}
21+
22+
set nftCollectionId(nftCollectionId: string) {
23+
this._nftCollectionId = nftCollectionId;
24+
}
25+
26+
get tokenId(): string {
27+
return this._tokenId;
28+
}
29+
30+
set tokenId(tokenId: string) {
31+
this._tokenId = tokenId;
32+
}
33+
34+
buildClauses(): void {
35+
if (!this.nftCollectionId) {
36+
throw new Error('NFT collection id is not set');
37+
}
38+
39+
if (!this.sender) {
40+
throw new Error('Sender address is not set');
41+
}
42+
43+
if (!this.tokenId) {
44+
throw new Error('Token id is not set');
45+
}
46+
47+
this.clauses = this.recipients.map((recipient) => {
48+
const data = utils.getTransferNFTData(this.sender, recipient.address, this.tokenId);
49+
return {
50+
to: this.nftCollectionId,
51+
value: '0x0',
52+
data,
53+
};
54+
});
55+
}
56+
57+
toJson(): VetTransactionData {
58+
const json: VetTransactionData = {
59+
id: this.id,
60+
chainTag: this.chainTag,
61+
blockRef: this.blockRef,
62+
expiration: this.expiration,
63+
recipients: this.recipients,
64+
gasPriceCoef: this.gasPriceCoef,
65+
gas: this.gas,
66+
dependsOn: this.dependsOn,
67+
nonce: this.nonce,
68+
sender: this.sender,
69+
feePayer: this.feePayerAddress,
70+
nftCollectionId: this.nftCollectionId,
71+
tokenId: this.tokenId,
72+
};
73+
74+
return json;
75+
}
76+
77+
fromDeserializedSignedTransaction(signedTx: VetTransaction): void {
78+
try {
79+
if (!signedTx || !signedTx.body) {
80+
throw new InvalidTransactionError('Invalid transaction: missing transaction body');
81+
}
82+
83+
// Store the raw transaction
84+
this.rawTransaction = signedTx;
85+
86+
// Set transaction body properties
87+
const body = signedTx.body;
88+
this.chainTag = body.chainTag;
89+
this.blockRef = body.blockRef;
90+
this.expiration = body.expiration;
91+
this.clauses = body.clauses;
92+
this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128;
93+
this.gas = Number(body.gas);
94+
this.dependsOn = body.dependsOn;
95+
this.nonce = String(body.nonce);
96+
// Set recipients from clauses
97+
assert(body.clauses[0].to, 'nft collection id(contract address) address not found in the clauses');
98+
assert(body.clauses.length === 1, 'NFT transaction should have exactly one clause');
99+
this.nftCollectionId = body.clauses[0].to;
100+
const decodedData = utils.decodeTransferNFTData(body.clauses[0].data);
101+
this.recipients = decodedData.recipients;
102+
this.tokenId = decodedData.tokenId;
103+
this.sender = decodedData.sender;
104+
this.loadInputsAndOutputs();
105+
106+
// Set signatures if present
107+
if (signedTx.signature) {
108+
// First signature is sender's signature
109+
this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH));
110+
111+
// If there's additional signature data, it's the fee payer's signature
112+
if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) {
113+
this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH));
114+
}
115+
}
116+
} catch (e) {
117+
throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`);
118+
}
119+
}
120+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,11 @@ export class Transaction extends BaseTransaction {
371371
nonce: this.nonce,
372372
};
373373

374-
if (this.type === TransactionType.Send || this.type === TransactionType.SendToken) {
374+
if (
375+
this.type === TransactionType.Send ||
376+
this.type === TransactionType.SendToken ||
377+
this.type === TransactionType.SendNFT
378+
) {
375379
transactionBody.reserved = {
376380
features: 1, // mark transaction as delegated i.e. will use gas payer
377381
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import BigNumber from 'bignumber.js';
2+
import { TransactionClause } from '@vechain/sdk-core';
3+
4+
import { BuildTransactionError, Recipient, TransactionType } from '@bitgo/sdk-core';
5+
import { BaseCoin as CoinConfig } from '@bitgo/statics';
6+
7+
import { TransactionBuilder } from './transactionBuilder';
8+
import { NFTTransaction } from '../transaction/nftTransaction';
9+
import utils from '../utils';
10+
11+
export class NFTTransactionBuilder extends TransactionBuilder {
12+
constructor(_coinConfig: Readonly<CoinConfig>) {
13+
super(_coinConfig);
14+
}
15+
16+
initBuilder(tx: NFTTransaction): void {
17+
super.initBuilder(tx);
18+
}
19+
20+
get nftTransaction(): NFTTransaction {
21+
return this._transaction as NFTTransaction;
22+
}
23+
24+
protected get transactionType(): TransactionType {
25+
return TransactionType.SendNFT;
26+
}
27+
28+
validateRecipientValue(value: BigNumber): void {
29+
if (value.isNaN()) {
30+
throw new BuildTransactionError('Invalid amount format');
31+
} else if (!value.isEqualTo(1)) {
32+
throw new BuildTransactionError('Value cannot be anything other than 1 for NFT transfer');
33+
}
34+
}
35+
36+
recipients(recipients: Recipient[]): this {
37+
for (const recipient of recipients) {
38+
this.validateAddress({ address: recipient.address });
39+
this.validateRecipientValue(new BigNumber(recipient.amount));
40+
}
41+
this.transaction.recipients = recipients;
42+
return this;
43+
}
44+
45+
/**
46+
* Validates the transaction clauses for NFT transaction.
47+
* @param {TransactionClause[]} clauses - The transaction clauses to validate.
48+
* @returns {boolean} - Returns true if the clauses are valid, false otherwise.
49+
*/
50+
protected isValidTransactionClauses(clauses: TransactionClause[]): boolean {
51+
try {
52+
if (!clauses || !Array.isArray(clauses) || clauses.length === 0) {
53+
return false;
54+
}
55+
56+
const clause = clauses[0];
57+
58+
if (!clause.to || !utils.isValidAddress(clause.to)) {
59+
return false;
60+
}
61+
62+
// For NFT transactions, the value should be 0
63+
if (clause.value !== 0) {
64+
return false;
65+
}
66+
67+
const { recipients, sender } = utils.decodeTransferNFTData(clause.data);
68+
69+
const recipientAddress = recipients[0].address.toLowerCase();
70+
71+
if (!recipientAddress || !utils.isValidAddress(recipientAddress)) {
72+
return false;
73+
}
74+
75+
if (!sender || !utils.isValidAddress(sender)) {
76+
return false;
77+
}
78+
79+
return true;
80+
} catch (e) {
81+
return false;
82+
}
83+
}
84+
85+
nftCollectionId(nftCollectionId: string): this {
86+
// nftCollectionId is basically a contract address, so we can use the same validation
87+
try {
88+
this.validateAddress({ address: nftCollectionId });
89+
} catch (e) {
90+
throw new BuildTransactionError('Invalid nftCollectionId, must be a valid contract address');
91+
}
92+
this.nftTransaction.nftCollectionId = nftCollectionId;
93+
return this;
94+
}
95+
96+
tokenId(tokenId: string): this {
97+
const tokenIdBN = new BigNumber(tokenId);
98+
if (!tokenIdBN.isInteger() || tokenIdBN.isNegative()) {
99+
throw new Error('Invalid tokenId, must be a non-negative integer');
100+
}
101+
this.nftTransaction.tokenId = tokenIdBN.toFixed(0);
102+
return this;
103+
}
104+
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { TokenTransactionBuilder } from './transactionBuilder/tokenTransactionBu
1717
import { TokenTransaction } from './transaction/tokenTransaction';
1818
import { StakingBuilder } from './transactionBuilder/stakingBuilder';
1919
import { StakingTransaction } from './transaction/stakingTransaction';
20+
import { NFTTransactionBuilder } from './transactionBuilder/nftTransactionBuilder';
21+
import { NFTTransaction } from './transaction/nftTransaction';
2022

2123
export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
2224
constructor(_coinConfig: Readonly<CoinConfig>) {
@@ -45,6 +47,10 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
4547
const tokenTransferTx = new TokenTransaction(this._coinConfig);
4648
tokenTransferTx.fromDeserializedSignedTransaction(signedTx);
4749
return this.getTokenTransactionBuilder(tokenTransferTx);
50+
case TransactionType.SendNFT:
51+
const nftTransferTx = new NFTTransaction(this._coinConfig);
52+
nftTransferTx.fromDeserializedSignedTransaction(signedTx);
53+
return this.getNFTTransactionBuilder(nftTransferTx);
4854
case TransactionType.ContractCall:
4955
const stakingTx = new StakingTransaction(this._coinConfig);
5056
stakingTx.fromDeserializedSignedTransaction(signedTx);
@@ -86,6 +92,16 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory {
8692
return this.initializeBuilder(tx, new StakingBuilder(this._coinConfig));
8793
}
8894

95+
/**
96+
* Gets an nft transaction builder.
97+
*
98+
* @param {NFTTransaction} tx - The nft transaction to use
99+
* @returns {NFTTransactionBuilder} The nft transaction builder
100+
*/
101+
getNFTTransactionBuilder(tx?: Transaction): NFTTransactionBuilder {
102+
return this.initializeBuilder(tx, new NFTTransactionBuilder(this._coinConfig));
103+
}
104+
89105
/**
90106
* Gets an exit delegation transaction builder.
91107
*

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
VET_ADDRESS_LENGTH,
1717
VET_BLOCK_ID_LENGTH,
1818
VET_TRANSACTION_ID_LENGTH,
19+
TRANSFER_NFT_METHOD_ID,
1920
} from './constants';
2021
import { KeyPair } from './keyPair';
2122

@@ -87,6 +88,8 @@ export class Utils implements BaseUtils {
8788
return TransactionType.StakingUnlock; // Using StakingUnlock for exit delegation
8889
} else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) {
8990
return TransactionType.StakingWithdraw; // Using StakingWithdraw for burn NFT
91+
} else if (clauses[0].data.startsWith(TRANSFER_NFT_METHOD_ID)) {
92+
return TransactionType.SendNFT;
9093
} else {
9194
return TransactionType.SendToken;
9295
}
@@ -103,6 +106,17 @@ export class Utils implements BaseUtils {
103106
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
104107
}
105108

109+
getTransferNFTData(from: string, to: string, tokenId: string): string {
110+
const methodName = 'transferFrom';
111+
const types = ['address', 'address', 'uint256'];
112+
const params = [from, to, new BN(tokenId)];
113+
114+
const method = EthereumAbi.methodID(methodName, types);
115+
const args = EthereumAbi.rawEncode(types, params);
116+
117+
return addHexPrefix(Buffer.concat([method, args]).toString('hex'));
118+
}
119+
106120
/**
107121
* Encodes staking transaction data using ethereumjs-abi
108122
*
@@ -131,6 +145,29 @@ export class Utils implements BaseUtils {
131145
amount: amount.toString(),
132146
};
133147
}
148+
149+
decodeTransferNFTData(data: string): {
150+
recipients: TransactionRecipient[];
151+
sender: string;
152+
tokenId: string;
153+
} {
154+
const [from, to, tokenIdBN] = getRawDecoded(
155+
['address', 'address', 'uint256'],
156+
getBufferedByteCode(TRANSFER_NFT_METHOD_ID, data)
157+
);
158+
const recipientAddress = addHexPrefix(to.toString()).toLowerCase();
159+
const recipient: TransactionRecipient = {
160+
address: recipientAddress,
161+
amount: '1',
162+
};
163+
const sender = addHexPrefix(from.toString()).toLowerCase();
164+
const tokenId = tokenIdBN.toString();
165+
return {
166+
recipients: [recipient],
167+
sender,
168+
tokenId,
169+
};
170+
}
134171
}
135172

136173
const utils = new Utils();

0 commit comments

Comments
 (0)