diff --git a/modules/sdk-coin-vet/src/lib/constants.ts b/modules/sdk-coin-vet/src/lib/constants.ts index 4269214d12..d43c42fd38 100644 --- a/modules/sdk-coin-vet/src/lib/constants.ts +++ b/modules/sdk-coin-vet/src/lib/constants.ts @@ -3,3 +3,8 @@ export const VET_ADDRESS_LENGTH = 40; export const VET_BLOCK_ID_LENGTH = 64; export const TRANSFER_TOKEN_METHOD_ID = '0xa9059cbb'; +export const EXIT_DELEGATION_METHOD_ID = '0x32b7006d'; +export const BURN_NFT_METHOD_ID = '0x42966c68'; + +export const STARGATE_NFT_ADDRESS = '0x1856c533ac2d94340aaa8544d35a5c1d4a21dee7'; +export const STARGATE_DELEGATION_ADDRESS = '0x4cb1c9ef05b529c093371264fab2c93cc6cddb0e'; diff --git a/modules/sdk-coin-vet/src/lib/iface.ts b/modules/sdk-coin-vet/src/lib/iface.ts index 2a966e51a5..b733efa12f 100644 --- a/modules/sdk-coin-vet/src/lib/iface.ts +++ b/modules/sdk-coin-vet/src/lib/iface.ts @@ -24,6 +24,7 @@ export interface VetTransactionData { deployedAddress?: string; to?: string; tokenAddress?: string; + tokenId?: string; // Added for unstaking and burn NFT transactions } export interface VetTransactionExplanation extends BaseTransactionExplanation { diff --git a/modules/sdk-coin-vet/src/lib/transaction/burnNftTransaction.ts b/modules/sdk-coin-vet/src/lib/transaction/burnNftTransaction.ts new file mode 100644 index 0000000000..e325c8174f --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/burnNftTransaction.ts @@ -0,0 +1,137 @@ +import { TransactionType, InvalidTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction as VetTransaction, Secp256k1 } from '@vechain/sdk-core'; +import { Transaction } from './transaction'; +import { VetTransactionData } from '../iface'; +import { BURN_NFT_METHOD_ID } from '../constants'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; + +export class BurnNftTransaction extends Transaction { + private _tokenId: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingWithdraw; + } + + get tokenId(): string { + return this._tokenId; + } + + set tokenId(id: string) { + this._tokenId = id; + } + + /** @inheritdoc */ + async build(): Promise { + this.buildClauses(); + await this.buildRawTransaction(); + this.generateTxnIdAndSetSender(); + this.loadInputsAndOutputs(); + } + + /** @inheritdoc */ + buildClauses(): void { + if (!this._contract || !this._tokenId) { + throw new InvalidTransactionError('Missing required burn NFT parameters'); + } + + this._clauses = [ + { + to: this._contract, + value: '0x0', + data: this._transactionData || this.getBurnNftData(), + }, + ]; + } + + /** + * Generates the transaction data for burning NFT by encoding the burn method call. + * + * @private + * @returns {string} The encoded transaction data as a hex string + */ + private getBurnNftData(): string { + const methodName = 'burn'; + const types = ['uint256']; + const params = [this._tokenId]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } + + /** @inheritdoc */ + toJson(): VetTransactionData { + const json: VetTransactionData = { + id: this.id, + chainTag: this.chainTag, + blockRef: this.blockRef, + expiration: this.expiration, + gasPriceCoef: this.gasPriceCoef, + gas: this.gas, + dependsOn: this.dependsOn, + nonce: this.nonce, + data: this.transactionData || this.getBurnNftData(), + value: '0', + sender: this.sender, + to: this.contract, + }; + return json; + } + + /** @inheritdoc */ + fromDeserializedSignedTransaction(signedTx: VetTransaction): void { + try { + if (!signedTx || !signedTx.body) { + throw new InvalidTransactionError('Invalid transaction: missing transaction body'); + } + + // Store the raw transaction + this.rawTransaction = signedTx; + + // Set transaction body properties + const body = signedTx.body; + this.chainTag = typeof body.chainTag === 'number' ? body.chainTag : 0; + this.blockRef = body.blockRef || '0x0'; + this.expiration = typeof body.expiration === 'number' ? body.expiration : 64; + this.clauses = body.clauses || []; + this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128; + this.gas = typeof body.gas === 'number' ? body.gas : Number(body.gas) || 0; + this.dependsOn = body.dependsOn || null; + this.nonce = String(body.nonce); + + // Set data from clauses + this.contract = body.clauses[0]?.to || '0x0'; + this.transactionData = body.clauses[0]?.data || '0x0'; + this.type = TransactionType.StakingWithdraw; + + // Extract tokenId from transaction data + if (this.transactionData.startsWith(BURN_NFT_METHOD_ID)) { + const tokenIdHex = this.transactionData.slice(BURN_NFT_METHOD_ID.length); + // Convert hex to decimal + this.tokenId = parseInt(tokenIdHex, 16).toString(); + } + + // Set sender address + if (signedTx.origin) { + this.sender = signedTx.origin.toString().toLowerCase(); + } + + // Set signatures if present + if (signedTx.signature) { + // First signature is sender's signature + this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH)); + + // If there's additional signature data, it's the fee payer's signature + if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) { + this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH)); + } + } + } catch (e) { + throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`); + } + } +} diff --git a/modules/sdk-coin-vet/src/lib/transaction/exitDelegation.ts b/modules/sdk-coin-vet/src/lib/transaction/exitDelegation.ts new file mode 100644 index 0000000000..2e9b678c70 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transaction/exitDelegation.ts @@ -0,0 +1,137 @@ +import { TransactionType, InvalidTransactionError } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import { Transaction as VetTransaction, Secp256k1 } from '@vechain/sdk-core'; +import { Transaction } from './transaction'; +import { VetTransactionData } from '../iface'; +import { EXIT_DELEGATION_METHOD_ID } from '../constants'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; + +export class ExitDelegationTransaction extends Transaction { + private _tokenId: string; + + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._type = TransactionType.StakingUnlock; + } + + get tokenId(): string { + return this._tokenId; + } + + set tokenId(id: string) { + this._tokenId = id; + } + + /** @inheritdoc */ + async build(): Promise { + this.buildClauses(); + await this.buildRawTransaction(); + this.generateTxnIdAndSetSender(); + this.loadInputsAndOutputs(); + } + + /** @inheritdoc */ + buildClauses(): void { + if (!this._contract || !this._tokenId) { + throw new InvalidTransactionError('Missing required unstaking parameters'); + } + + this._clauses = [ + { + to: this._contract, + value: '0x0', + data: this._transactionData || this.getExitDelegationData(), + }, + ]; + } + + /** + * Generates the transaction data for unstaking by encoding the exitDelegation method call. + * + * @private + * @returns {string} The encoded transaction data as a hex string + */ + private getExitDelegationData(): string { + const methodName = 'exitDelegation'; + const types = ['uint256']; + const params = [this._tokenId]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } + + /** @inheritdoc */ + toJson(): VetTransactionData { + const json: VetTransactionData = { + id: this.id, + chainTag: this.chainTag, + blockRef: this.blockRef, + expiration: this.expiration, + gasPriceCoef: this.gasPriceCoef, + gas: this.gas, + dependsOn: this.dependsOn, + nonce: this.nonce, + data: this.transactionData || this.getExitDelegationData(), + value: '0', + sender: this.sender, + to: this.contract, + }; + return json; + } + + /** @inheritdoc */ + fromDeserializedSignedTransaction(signedTx: VetTransaction): void { + try { + if (!signedTx || !signedTx.body) { + throw new InvalidTransactionError('Invalid transaction: missing transaction body'); + } + + // Store the raw transaction + this.rawTransaction = signedTx; + + // Set transaction body properties + const body = signedTx.body; + this.chainTag = typeof body.chainTag === 'number' ? body.chainTag : 0; + this.blockRef = body.blockRef || '0x0'; + this.expiration = typeof body.expiration === 'number' ? body.expiration : 64; + this.clauses = body.clauses || []; + this.gasPriceCoef = typeof body.gasPriceCoef === 'number' ? body.gasPriceCoef : 128; + this.gas = typeof body.gas === 'number' ? body.gas : Number(body.gas) || 0; + this.dependsOn = body.dependsOn || null; + this.nonce = String(body.nonce); + + // Set data from clauses + this.contract = body.clauses[0]?.to || '0x0'; + this.transactionData = body.clauses[0]?.data || '0x0'; + this.type = TransactionType.StakingUnlock; + + // Extract tokenId from transaction data + if (this.transactionData.startsWith(EXIT_DELEGATION_METHOD_ID)) { + const tokenIdHex = this.transactionData.slice(EXIT_DELEGATION_METHOD_ID.length); + // Convert hex to decimal + this.tokenId = parseInt(tokenIdHex, 16).toString(); + } + + // Set sender address + if (signedTx.origin) { + this.sender = signedTx.origin.toString().toLowerCase(); + } + + // Set signatures if present + if (signedTx.signature) { + // First signature is sender's signature + this.senderSignature = Buffer.from(signedTx.signature.slice(0, Secp256k1.SIGNATURE_LENGTH)); + + // If there's additional signature data, it's the fee payer's signature + if (signedTx.signature.length > Secp256k1.SIGNATURE_LENGTH) { + this.feePayerSignature = Buffer.from(signedTx.signature.slice(Secp256k1.SIGNATURE_LENGTH)); + } + } + } catch (e) { + throw new InvalidTransactionError(`Failed to deserialize transaction: ${e.message}`); + } + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/burnNftBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/burnNftBuilder.ts new file mode 100644 index 0000000000..1a9bc493a7 --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/burnNftBuilder.ts @@ -0,0 +1,143 @@ +import assert from 'assert'; +import { TransactionClause } from '@vechain/sdk-core'; +import { TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; + +import { TransactionBuilder } from './transactionBuilder'; +import { BurnNftTransaction } from '../transaction/burnNftTransaction'; +import { Transaction } from '../transaction/transaction'; +import utils from '../utils'; +import { BURN_NFT_METHOD_ID, STARGATE_NFT_ADDRESS } from '../constants'; + +export class BurnNftBuilder extends TransactionBuilder { + /** + * Creates a new BurnNftBuilder instance. + * + * @param {Readonly} _coinConfig - The coin configuration object + */ + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new BurnNftTransaction(_coinConfig); + } + + /** + * Initializes the builder with an existing BurnNftTransaction. + * + * @param {BurnNftTransaction} tx - The transaction to initialize the builder with + */ + initBuilder(tx: BurnNftTransaction): void { + this._transaction = tx; + } + + /** + * Gets the burn NFT transaction instance. + * + * @returns {BurnNftTransaction} The burn NFT transaction + */ + get burnNftTransaction(): BurnNftTransaction { + return this._transaction as BurnNftTransaction; + } + + /** + * Gets the transaction type for burning NFT. + * + * @returns {TransactionType} The transaction type + */ + protected get transactionType(): TransactionType { + return TransactionType.StakingWithdraw; + } + + /** + * Validates the transaction clauses for burning NFT. + * @param {TransactionClause[]} clauses - The transaction clauses to validate. + * @returns {boolean} - Returns true if the clauses are valid, false otherwise. + */ + protected isValidTransactionClauses(clauses: TransactionClause[]): boolean { + try { + if (!clauses || !Array.isArray(clauses) || clauses.length === 0) { + return false; + } + + const clause = clauses[0]; + + if (!clause.to || !utils.isValidAddress(clause.to)) { + return false; + } + + // For burn NFT transactions, value must be exactly '0x0' + if (clause.value !== 0) { + return false; + } + + // Check if the data starts with the burn method ID + if (!clause.data.startsWith(BURN_NFT_METHOD_ID)) { + return false; + } + + return true; + } catch (e) { + return false; + } + } + + /** + * Sets the token ID for this burn NFT transaction. + * + * @param {string} tokenId - The ID of the NFT token to burn + * @returns {BurnNftBuilder} This transaction builder + */ + tokenId(tokenId: string): this { + this.burnNftTransaction.tokenId = tokenId; + return this; + } + + /** + * Sets the NFT contract address for this burn NFT transaction. + * If not provided, uses the default address from constants. + * + * @param {string} address - The NFT contract address + * @returns {BurnNftBuilder} This transaction builder + */ + nftContract(address: string = STARGATE_NFT_ADDRESS): this { + this.validateAddress({ address }); + this.burnNftTransaction.contract = address; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: BurnNftTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + assert(transaction.contract, 'NFT contract address is required'); + assert(transaction.tokenId, 'Token ID is required'); + + this.validateAddress({ address: transaction.contract }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.burnNftTransaction.build(); + return this.transaction; + } + + /** + * Generates the transaction data for burning NFT by encoding the burn method call. + * + * @private + * @returns {string} The encoded transaction data as a hex string + */ + private getBurnNftData(): string { + const methodName = 'burn'; + const types = ['uint256']; + const params = [this.burnNftTransaction.tokenId]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilder/exitDelegationBuilder.ts b/modules/sdk-coin-vet/src/lib/transactionBuilder/exitDelegationBuilder.ts new file mode 100644 index 0000000000..f11d8be77b --- /dev/null +++ b/modules/sdk-coin-vet/src/lib/transactionBuilder/exitDelegationBuilder.ts @@ -0,0 +1,143 @@ +import assert from 'assert'; +import { TransactionClause } from '@vechain/sdk-core'; +import { TransactionType } from '@bitgo/sdk-core'; +import { BaseCoin as CoinConfig } from '@bitgo/statics'; +import EthereumAbi from 'ethereumjs-abi'; +import { addHexPrefix } from 'ethereumjs-util'; + +import { TransactionBuilder } from './transactionBuilder'; +import { ExitDelegationTransaction } from '../transaction/exitDelegation'; +import { Transaction } from '../transaction/transaction'; +import utils from '../utils'; +import { EXIT_DELEGATION_METHOD_ID, STARGATE_DELEGATION_ADDRESS } from '../constants'; + +export class ExitDelegationBuilder extends TransactionBuilder { + /** + * Creates a new ExitDelegationBuilder instance. + * + * @param {Readonly} _coinConfig - The coin configuration object + */ + constructor(_coinConfig: Readonly) { + super(_coinConfig); + this._transaction = new ExitDelegationTransaction(_coinConfig); + } + + /** + * Initializes the builder with an existing ExitDelegationTransaction. + * + * @param {ExitDelegationTransaction} tx - The transaction to initialize the builder with + */ + initBuilder(tx: ExitDelegationTransaction): void { + this._transaction = tx; + } + + /** + * Gets the exit delegation transaction instance. + * + * @returns {ExitDelegationTransaction} The exit delegation transaction + */ + get exitDelegationTransaction(): ExitDelegationTransaction { + return this._transaction as ExitDelegationTransaction; + } + + /** + * Gets the transaction type for unstaking. + * + * @returns {TransactionType} The transaction type + */ + protected get transactionType(): TransactionType { + return TransactionType.StakingUnlock; + } + + /** + * Validates the transaction clauses for unstaking. + * @param {TransactionClause[]} clauses - The transaction clauses to validate. + * @returns {boolean} - Returns true if the clauses are valid, false otherwise. + */ + protected isValidTransactionClauses(clauses: TransactionClause[]): boolean { + try { + if (!clauses || !Array.isArray(clauses) || clauses.length === 0) { + return false; + } + + const clause = clauses[0]; + + if (!clause.to || !utils.isValidAddress(clause.to)) { + return false; + } + + // For unstaking transactions, value must be exactly '0x0' + if (clause.value !== 0) { + return false; + } + + // Check if the data starts with the exitDelegation method ID + if (!clause.data.startsWith(EXIT_DELEGATION_METHOD_ID)) { + return false; + } + + return true; + } catch (e) { + return false; + } + } + + /** + * Sets the token ID for this unstaking transaction. + * + * @param {string} tokenId - The ID of the NFT token to unstake + * @returns {ExitDelegationBuilder} This transaction builder + */ + tokenId(tokenId: string): this { + this.exitDelegationTransaction.tokenId = tokenId; + return this; + } + + /** + * Sets the delegation contract address for this unstaking transaction. + * If not provided, uses the default address from constants. + * + * @param {string} address - The delegation contract address + * @returns {ExitDelegationBuilder} This transaction builder + */ + delegationContract(address: string = STARGATE_DELEGATION_ADDRESS): this { + this.validateAddress({ address }); + this.exitDelegationTransaction.contract = address; + return this; + } + + /** @inheritdoc */ + validateTransaction(transaction?: ExitDelegationTransaction): void { + if (!transaction) { + throw new Error('transaction not defined'); + } + assert(transaction.contract, 'Delegation contract address is required'); + assert(transaction.tokenId, 'Token ID is required'); + + this.validateAddress({ address: transaction.contract }); + } + + /** @inheritdoc */ + protected async buildImplementation(): Promise { + this.transaction.type = this.transactionType; + await this.exitDelegationTransaction.build(); + return this.transaction; + } + + /** + * Generates the transaction data for exit delegation by encoding the exitDelegation method call. + * + * @private + * @returns {string} The encoded transaction data as a hex string + */ + private getExitDelegationData(): string { + const methodName = 'exitDelegation'; + const types = ['uint256']; + const params = [this.exitDelegationTransaction.tokenId]; + + const method = EthereumAbi.methodID(methodName, types); + const args = EthereumAbi.rawEncode(types, params); + + return addHexPrefix(Buffer.concat([method, args]).toString('hex')); + } +} diff --git a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts index 03050f4876..eb4fc10239 100644 --- a/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts +++ b/modules/sdk-coin-vet/src/lib/transactionBuilderFactory.ts @@ -5,10 +5,14 @@ import { TransactionBuilder } from './transactionBuilder/transactionBuilder'; import { TransferBuilder } from './transactionBuilder/transferBuilder'; import { AddressInitializationBuilder } from './transactionBuilder/addressInitializationBuilder'; import { FlushTokenTransactionBuilder } from './transactionBuilder/flushTokenTransactionBuilder'; +import { ExitDelegationBuilder } from './transactionBuilder/exitDelegationBuilder'; +import { BurnNftBuilder } from './transactionBuilder/burnNftBuilder'; import { Transaction } from './transaction/transaction'; import utils from './utils'; import { AddressInitializationTransaction } from './transaction/addressInitializationTransaction'; import { FlushTokenTransaction } from './transaction/flushTokenTransaction'; +import { ExitDelegationTransaction } from './transaction/exitDelegation'; +import { BurnNftTransaction } from './transaction/burnNftTransaction'; import { TokenTransactionBuilder } from './transactionBuilder/tokenTransactionBuilder'; import { TokenTransaction } from './transaction/tokenTransaction'; @@ -39,6 +43,14 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { const tokenTransferTx = new TokenTransaction(this._coinConfig); tokenTransferTx.fromDeserializedSignedTransaction(signedTx); return this.getTokenTransactionBuilder(tokenTransferTx); + case TransactionType.StakingUnlock: + const exitDelegationTx = new ExitDelegationTransaction(this._coinConfig); + exitDelegationTx.fromDeserializedSignedTransaction(signedTx); + return this.getExitDelegationBuilder(exitDelegationTx); + case TransactionType.StakingWithdraw: + const burnNftTx = new BurnNftTransaction(this._coinConfig); + burnNftTx.fromDeserializedSignedTransaction(signedTx); + return this.getBurnNftBuilder(burnNftTx); default: throw new InvalidTransactionError('Invalid transaction type'); } @@ -64,6 +76,26 @@ export class TransactionBuilderFactory extends BaseTransactionBuilderFactory { return this.initializeBuilder(tx, new TokenTransactionBuilder(this._coinConfig)); } + /** + * Gets an exit delegation transaction builder. + * + * @param {ExitDelegationTransaction} tx - The exit delegation transaction to use + * @returns {ExitDelegationBuilder} The exit delegation transaction builder + */ + getExitDelegationBuilder(tx?: ExitDelegationTransaction): ExitDelegationBuilder { + return this.initializeBuilder(tx, new ExitDelegationBuilder(this._coinConfig)); + } + + /** + * Gets a burn NFT transaction builder. + * + * @param {BurnNftTransaction} tx - The burn NFT transaction to use + * @returns {BurnNftBuilder} The burn NFT transaction builder + */ + getBurnNftBuilder(tx?: BurnNftTransaction): BurnNftBuilder { + return this.initializeBuilder(tx, new BurnNftBuilder(this._coinConfig)); + } + /** @inheritdoc */ getWalletInitializationBuilder(): void { throw new Error('Method not implemented.'); diff --git a/modules/sdk-coin-vet/src/lib/utils.ts b/modules/sdk-coin-vet/src/lib/utils.ts index 7739436d8d..5cd52829e7 100644 --- a/modules/sdk-coin-vet/src/lib/utils.ts +++ b/modules/sdk-coin-vet/src/lib/utils.ts @@ -10,6 +10,8 @@ import { } from '@bitgo/abstract-eth'; import { TRANSFER_TOKEN_METHOD_ID, + EXIT_DELEGATION_METHOD_ID, + BURN_NFT_METHOD_ID, VET_ADDRESS_LENGTH, VET_BLOCK_ID_LENGTH, VET_TRANSACTION_ID_LENGTH, @@ -78,6 +80,10 @@ export class Utils implements BaseUtils { return TransactionType.FlushTokens; } else if (clauses[0].data.startsWith(TRANSFER_TOKEN_METHOD_ID)) { return TransactionType.SendToken; + } else if (clauses[0].data.startsWith(EXIT_DELEGATION_METHOD_ID)) { + return TransactionType.StakingUnlock; // Using StakingUnlock for exit delegation + } else if (clauses[0].data.startsWith(BURN_NFT_METHOD_ID)) { + return TransactionType.StakingWithdraw; // Using StakingWithdraw for burn NFT } else { return TransactionType.SendToken; } diff --git a/modules/sdk-coin-vet/test/transactionBuilder/burnNftBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/burnNftBuilder.ts new file mode 100644 index 0000000000..4e9e8fc275 --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/burnNftBuilder.ts @@ -0,0 +1,129 @@ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src'; +import { BurnNftTransaction } from '../../src/lib/transaction/burnNftTransaction'; +import * as testData from '../resources/vet'; +import { BURN_NFT_METHOD_ID, STARGATE_NFT_ADDRESS } from '../../src/lib/constants'; + +describe('Vet Burn NFT Transaction', () => { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + + describe('Build and Sign', () => { + it('should build a burn NFT transaction', async function () { + const tokenId = '123456'; + const txBuilder = factory.getBurnNftBuilder(); + + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.tokenId(tokenId); + txBuilder.nftContract(); // Use default address + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + + const tx = (await txBuilder.build()) as BurnNftTransaction; + + should.equal(tx.sender, testData.addresses.validAddresses[0]); + should.equal(tx.tokenId, tokenId); + should.equal(tx.contract, STARGATE_NFT_ADDRESS); + should.equal(tx.gas, 21000); + should.equal(tx.nonce, '64248'); + should.equal(tx.expiration, 64); + should.equal(tx.type, TransactionType.StakingWithdraw); + + // Verify the transaction data contains the correct method ID + tx.clauses[0].data.should.startWith(BURN_NFT_METHOD_ID); + + // Verify the transaction has the correct structure + tx.clauses.length.should.equal(1); + should.equal(tx.clauses[0].to, STARGATE_NFT_ADDRESS); + should.equal(tx.clauses[0].value, '0x0'); + }); + + it('should build a burn NFT transaction with custom contract address', async function () { + const tokenId = '123456'; + const customContractAddress = '0x1234567890123456789012345678901234567890'; + const txBuilder = factory.getBurnNftBuilder(); + + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.tokenId(tokenId); + txBuilder.nftContract(customContractAddress); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + + const tx = (await txBuilder.build()) as BurnNftTransaction; + + should.equal(tx.contract, customContractAddress); + should.equal(tx.clauses[0].to, customContractAddress); + }); + + it('should deserialize and reserialize a signed burn NFT transaction', async function () { + // Create a mock serialized transaction for burn NFT + const tokenId = '123456'; + const txBuilder = factory.getBurnNftBuilder(); + + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.tokenId(tokenId); + txBuilder.nftContract(); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + + const tx = (await txBuilder.build()) as BurnNftTransaction; + const serializedTx = tx.toBroadcastFormat(); + + // Now deserialize and check + const deserializedBuilder = factory.from(serializedTx); + const deserializedTx = (await deserializedBuilder.build()) as BurnNftTransaction; + + should.equal(deserializedTx.type, TransactionType.StakingWithdraw); + should.equal(deserializedTx.tokenId, tokenId); + should.equal(deserializedTx.contract, STARGATE_NFT_ADDRESS); + }); + + it('should validate the transaction data structure', async function () { + const txBuilder = factory.getBurnNftBuilder(); + + // Should throw error when building without required fields + await should(txBuilder.build()).be.rejectedWith('transaction not defined'); + + txBuilder.sender(testData.addresses.validAddresses[0]); + await should(txBuilder.build()).be.rejectedWith('NFT contract address is required'); + + txBuilder.nftContract(); + await should(txBuilder.build()).be.rejectedWith('Token ID is required'); + + // Now add the token ID and it should build successfully + txBuilder.tokenId('123456'); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + + const tx = await txBuilder.build(); + should.exist(tx); + }); + }); + + describe('Validation', () => { + it('should fail with invalid contract address', function () { + const txBuilder = factory.getBurnNftBuilder(); + should(() => txBuilder.nftContract('invalid-address')).throwError('Invalid address invalid-address'); + }); + + it('should fail with invalid token ID', async function () { + const txBuilder = factory.getBurnNftBuilder(); + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.nftContract(); + txBuilder.tokenId(''); + + await should(txBuilder.build()).be.rejectedWith('Token ID is required'); + }); + }); +}); diff --git a/modules/sdk-coin-vet/test/transactionBuilder/exitDelegationBuilder.ts b/modules/sdk-coin-vet/test/transactionBuilder/exitDelegationBuilder.ts new file mode 100644 index 0000000000..9db1135b01 --- /dev/null +++ b/modules/sdk-coin-vet/test/transactionBuilder/exitDelegationBuilder.ts @@ -0,0 +1,134 @@ +import should from 'should'; +import { TransactionType } from '@bitgo/sdk-core'; +import { coins } from '@bitgo/statics'; +import { TransactionBuilderFactory } from '../../src'; +import { ExitDelegationTransaction } from '../../src/lib/transaction/exitDelegation'; +import * as testData from '../resources/vet'; +import { EXIT_DELEGATION_METHOD_ID, STARGATE_DELEGATION_ADDRESS } from '../../src/lib/constants'; + +describe('Vet Exit Delegation Transaction', () => { + const factory = new TransactionBuilderFactory(coins.get('tvet')); + + describe('Build and Sign', () => { + it('should build an exit delegation transaction', async function () { + const tokenId = '123456'; + const txBuilder = factory.getExitDelegationBuilder(); + + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.tokenId(tokenId); + txBuilder.delegationContract(); // Use default address + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + + const tx = (await txBuilder.build()) as ExitDelegationTransaction; + + should.equal(tx.sender, testData.addresses.validAddresses[0]); + should.equal(tx.tokenId, tokenId); + should.equal(tx.contract, STARGATE_DELEGATION_ADDRESS); + should.equal(tx.gas, 21000); + should.equal(tx.nonce, '64248'); + should.equal(tx.expiration, 64); + should.equal(tx.type, TransactionType.StakingUnlock); + + // Verify the transaction data contains the correct method ID and tokenId + tx.clauses[0].data.should.startWith(EXIT_DELEGATION_METHOD_ID); + + // Verify the transaction has the correct structure + tx.clauses.length.should.equal(1); + should.exist(tx.clauses[0]); + should.exist(tx.clauses[0].to); + tx.clauses[0]?.to?.should.equal(STARGATE_DELEGATION_ADDRESS); + should.exist(tx.clauses[0].value); + tx.clauses[0].value.should.equal('0x0'); + }); + + it('should build an exit delegation transaction with custom contract address', async function () { + const tokenId = '123456'; + const customContractAddress = '0x1234567890123456789012345678901234567890'; + const txBuilder = factory.getExitDelegationBuilder(); + + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.tokenId(tokenId); + txBuilder.delegationContract(customContractAddress); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + + const tx = (await txBuilder.build()) as ExitDelegationTransaction; + + should.equal(tx.contract, customContractAddress); + should.exist(tx.clauses[0]); + should.exist(tx.clauses[0].to); + tx.clauses[0]?.to?.should.equal(customContractAddress); + }); + + it('should deserialize and reserialize a signed exit delegation transaction', async function () { + // Create a mock serialized transaction for exit delegation + const tokenId = '123456'; + const txBuilder = factory.getExitDelegationBuilder(); + + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.tokenId(tokenId); + txBuilder.delegationContract(); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + txBuilder.expiration(64); + txBuilder.gasPriceCoef(128); + + const tx = (await txBuilder.build()) as ExitDelegationTransaction; + const serializedTx = tx.toBroadcastFormat(); + + // Now deserialize and check + const deserializedBuilder = factory.from(serializedTx); + const deserializedTx = (await deserializedBuilder.build()) as ExitDelegationTransaction; + + should.equal(deserializedTx.type, TransactionType.StakingUnlock); + should.equal(deserializedTx.tokenId, tokenId); + should.equal(deserializedTx.contract, STARGATE_DELEGATION_ADDRESS); + }); + + it('should validate the transaction data structure', async function () { + const txBuilder = factory.getExitDelegationBuilder(); + + // Should throw error when building without required fields + await should(txBuilder.build()).be.rejectedWith('transaction not defined'); + + txBuilder.sender(testData.addresses.validAddresses[0]); + await should(txBuilder.build()).be.rejectedWith('Delegation contract address is required'); + + txBuilder.delegationContract(); + await should(txBuilder.build()).be.rejectedWith('Token ID is required'); + + // Now add the token ID and it should build successfully + txBuilder.tokenId('123456'); + txBuilder.gas(21000); + txBuilder.nonce('64248'); + txBuilder.blockRef('0x014ead140e77bbc1'); + + const tx = await txBuilder.build(); + should.exist(tx); + }); + }); + + describe('Validation', () => { + it('should fail with invalid contract address', function () { + const txBuilder = factory.getExitDelegationBuilder(); + should(() => txBuilder.delegationContract('invalid-address')).throwError('Invalid address invalid-address'); + }); + + it('should fail with invalid token ID', async function () { + const txBuilder = factory.getExitDelegationBuilder(); + txBuilder.sender(testData.addresses.validAddresses[0]); + txBuilder.delegationContract(); + txBuilder.tokenId(''); + + await should(txBuilder.build()).be.rejectedWith('Token ID is required'); + }); + }); +});