From a4a0f9be88b100cfe6591b4f4811c5ef672320f6 Mon Sep 17 00:00:00 2001 From: Rohit Saw Date: Tue, 14 Apr 2026 23:34:58 +0530 Subject: [PATCH] feat: hts nft transfer support ticket: cecho-39 --- .../transferBuilders/transferBuilderERC721.ts | 18 +++++- modules/abstract-eth/src/lib/utils.ts | 56 +++++++++++++------ modules/abstract-eth/src/lib/walletUtil.ts | 2 + modules/abstract-eth/test/unit/utils.ts | 40 +++++++++++++ .../test/unit/transactionBuilder/sendNFT.ts | 54 ++++++++++++++++++ modules/sdk-coin-evm/src/evmCoin.ts | 26 ++++++++- modules/sdk-coin-evm/src/lib/utils.ts | 12 ++++ modules/sdk-coin-evm/test/unit/utils.ts | 28 +++++++++- 8 files changed, 215 insertions(+), 21 deletions(-) diff --git a/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts b/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts index 0aa3c71f82..871583a8fd 100644 --- a/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts +++ b/modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts @@ -4,7 +4,12 @@ import { hexlify, hexZeroPad } from 'ethers/lib/utils'; import { ContractCall } from '../contractCall'; import { decodeERC721TransferData, isValidEthAddress, sendMultiSigData } from '../utils'; import { BaseNFTTransferBuilder } from './baseNFTTransferBuilder'; -import { ERC721SafeTransferTypeMethodId, ERC721SafeTransferTypes } from '../walletUtil'; +import { + ERC721SafeTransferTypeMethodId, + ERC721SafeTransferTypes, + ERC721TransferFromMethodId, + ERC721TransferFromTypes, +} from '../walletUtil'; import { coins, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics'; export class ERC721TransferBuilder extends BaseNFTTransferBuilder { @@ -54,6 +59,17 @@ export class ERC721TransferBuilder extends BaseNFTTransferBuilder { return contractCall.serialize(); } + /** + * Build using transferFrom(address,address,uint256) without the bytes data parameter. + * Required for HTS NFT transfers on Hedera EVM, which only supports transferFrom. + */ + buildTransferFrom(): string { + const types = ERC721TransferFromTypes; + const values = [this._fromAddress, this._toAddress, this._tokenId]; + const contractCall = new ContractCall(ERC721TransferFromMethodId, types, values); + return contractCall.serialize(); + } + signAndBuild(chainId: string): string { this._chainId = chainId; if (this.hasMandatoryFields()) { diff --git a/modules/abstract-eth/src/lib/utils.ts b/modules/abstract-eth/src/lib/utils.ts index aaecc58522..05dcdadc51 100644 --- a/modules/abstract-eth/src/lib/utils.ts +++ b/modules/abstract-eth/src/lib/utils.ts @@ -51,6 +51,8 @@ import { ERC1155SafeTransferTypes, ERC721SafeTransferTypeMethodId, ERC721SafeTransferTypes, + ERC721TransferFromMethodId, + ERC721TransferFromTypes, flushCoinsMethodId, flushCoinsTypes, flushForwarderTokensMethodId, @@ -509,26 +511,46 @@ export function decodeERC721TransferData(data: string): ERC721TransferData { ); const internalDataHex = bufferToHex(internalData as Buffer); - if (!internalDataHex.startsWith(ERC721SafeTransferTypeMethodId)) { - throw new BuildTransactionError(`Invalid transfer bytecode: ${data}`); + + if (internalDataHex.startsWith(ERC721SafeTransferTypeMethodId)) { + const [from, receiver, tokenId, userSentData] = getRawDecoded( + ERC721SafeTransferTypes, + getBufferedByteCode(ERC721SafeTransferTypeMethodId, internalDataHex) + ); + + return { + to: addHexPrefix(receiver as string), + from: addHexPrefix(from as string), + expireTime: bufferToInt(expireTime as Buffer), + amount: new BigNumber(bufferToHex(amount as Buffer)).toFixed(), + tokenId: new BigNumber(bufferToHex(tokenId as Buffer)).toFixed(), + sequenceId: bufferToInt(sequenceId as Buffer), + signature: bufferToHex(signature as Buffer), + tokenContractAddress: addHexPrefix(to as string), + userData: bufferToHex(userSentData as Buffer), + }; } - const [from, receiver, tokenId, userSentData] = getRawDecoded( - ERC721SafeTransferTypes, - getBufferedByteCode(ERC721SafeTransferTypeMethodId, internalDataHex) - ); + if (internalDataHex.startsWith(ERC721TransferFromMethodId)) { + const [from, receiver, tokenId] = getRawDecoded( + ERC721TransferFromTypes, + getBufferedByteCode(ERC721TransferFromMethodId, internalDataHex) + ); - return { - to: addHexPrefix(receiver as string), - from: addHexPrefix(from as string), - expireTime: bufferToInt(expireTime as Buffer), - amount: new BigNumber(bufferToHex(amount as Buffer)).toFixed(), - tokenId: new BigNumber(bufferToHex(tokenId as Buffer)).toFixed(), - sequenceId: bufferToInt(sequenceId as Buffer), - signature: bufferToHex(signature as Buffer), - tokenContractAddress: addHexPrefix(to as string), - userData: bufferToHex(userSentData as Buffer), - }; + return { + to: addHexPrefix(receiver as string), + from: addHexPrefix(from as string), + expireTime: bufferToInt(expireTime as Buffer), + amount: new BigNumber(bufferToHex(amount as Buffer)).toFixed(), + tokenId: new BigNumber(bufferToHex(tokenId as Buffer)).toFixed(), + sequenceId: bufferToInt(sequenceId as Buffer), + signature: bufferToHex(signature as Buffer), + tokenContractAddress: addHexPrefix(to as string), + userData: '', + }; + } + + throw new BuildTransactionError(`Invalid transfer bytecode: ${data}`); } export function decodeERC1155TransferData(data: string): ERC1155TransferData { diff --git a/modules/abstract-eth/src/lib/walletUtil.ts b/modules/abstract-eth/src/lib/walletUtil.ts index 728d26fd68..add5c680f6 100644 --- a/modules/abstract-eth/src/lib/walletUtil.ts +++ b/modules/abstract-eth/src/lib/walletUtil.ts @@ -16,6 +16,7 @@ export const flushERC1155ForwarderTokensMethodId = '0xe6bd0aa4'; export const flushERC1155ForwarderTokensMethodIdV4 = '0x8972c17c'; export const ERC721SafeTransferTypeMethodId = '0xb88d4fde'; +export const ERC721TransferFromMethodId = '0x23b872dd'; export const ERC1155SafeTransferTypeMethodId = '0xf242432a'; export const ERC1155BatchTransferTypeMethodId = '0x2eb2c2d6'; export const defaultForwarderVersion = 0; @@ -38,6 +39,7 @@ export const sendMultiSigTokenTypes = ['address', 'uint', 'address', 'uint', 'ui export const sendMultiSigTokenTypesFirstSigner = ['string', 'address', 'uint', 'address', 'uint', 'uint']; export const ERC721SafeTransferTypes = ['address', 'address', 'uint256', 'bytes']; +export const ERC721TransferFromTypes = ['address', 'address', 'uint256']; export const ERC1155SafeTransferTypes = ['address', 'address', 'uint256', 'uint256', 'bytes']; export const ERC1155BatchTransferTypes = ['address', 'address', 'uint256[]', 'uint256[]', 'bytes']; diff --git a/modules/abstract-eth/test/unit/utils.ts b/modules/abstract-eth/test/unit/utils.ts index 53ac91fe1b..68cd2e47fd 100644 --- a/modules/abstract-eth/test/unit/utils.ts +++ b/modules/abstract-eth/test/unit/utils.ts @@ -5,6 +5,8 @@ import { decodeFlushERC721TokensData, decodeFlushERC1155TokensData, } from '../../src/lib/utils'; +import { ERC721TransferBuilder } from '../../src/lib/transferBuilders/transferBuilderERC721'; +import { ERC721TransferFromMethodId, ERC721SafeTransferTypeMethodId } from '../../src/lib/walletUtil'; describe('Abstract ETH Utils', () => { describe('ERC721 Flush Functions', () => { @@ -209,6 +211,44 @@ describe('Abstract ETH Utils', () => { }); }); + describe('ERC721TransferBuilder.buildTransferFrom', () => { + const owner = '0x19645032c7f1533395d44a629462e751084d3e4d'; + const recipient = '0x19645032c7f1533395d44a629462e751084d3e4c'; + const htsNftAddress = '0x00000000000000000000000000000000007ac203'; + + it('should encode transferFrom with selector 0x23b872dd', () => { + const builder = new ERC721TransferBuilder(); + builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12'); + + const data = builder.buildTransferFrom(); + + should.exist(data); + data.should.startWith(ERC721TransferFromMethodId); // 0x23b872dd + }); + + it('should encode safeTransferFrom with selector 0xb88d4fde via build()', () => { + const builder = new ERC721TransferBuilder(); + builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12'); + + const data = builder.build(); + + should.exist(data); + data.should.startWith(ERC721SafeTransferTypeMethodId); // 0xb88d4fde + }); + + it('should produce different encodings for build() vs buildTransferFrom()', () => { + const builder = new ERC721TransferBuilder(); + builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12'); + + const safeTransferData = builder.build(); + const transferFromData = builder.buildTransferFrom(); + + safeTransferData.should.not.equal(transferFromData); + // transferFrom encoding should be shorter (no bytes param) + transferFromData.length.should.be.lessThan(safeTransferData.length); + }); + }); + describe('Token Address Validation', () => { it('should preserve address format in encoding/decoding', () => { const forwarderAddress = '0x8f977e912ef500548a0c3be6ddde9899f1199b81'; diff --git a/modules/sdk-coin-eth/test/unit/transactionBuilder/sendNFT.ts b/modules/sdk-coin-eth/test/unit/transactionBuilder/sendNFT.ts index 5cc21f4259..829a8e3de4 100644 --- a/modules/sdk-coin-eth/test/unit/transactionBuilder/sendNFT.ts +++ b/modules/sdk-coin-eth/test/unit/transactionBuilder/sendNFT.ts @@ -266,6 +266,60 @@ describe('Eth transaction builder sendNFT', () => { }); }); +// ABI for transferFrom(address,address,uint256) used by HTS native NFTs on Hedera EVM +const transferFromABI = [ + { + inputs: [ + { internalType: 'address', name: 'from', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +]; + +describe('ERC721 HTS native NFT transferFrom', () => { + const owner = '0x19645032c7f1533395d44a629462e751084d3e4d'; + const recipient = '0x19645032c7f1533395d44a629462e751084d3e4c'; + // HTS native NFT address (long-zero format) + const htsNftContractAddress = '0x00000000000000000000000000000000007ac203'; + const tokenId = '12'; + + it('should build ERC721 transferFrom calldata with correct selector and params', () => { + const builder = new ERC721TransferBuilder(); + builder.tokenContractAddress(htsNftContractAddress).to(recipient).from(owner).tokenId(tokenId); + + const calldata = builder.buildTransferFrom(); + + // Should use transferFrom selector 0x23b872dd + calldata.should.startWith('0x23b872dd'); + + // Decode and verify parameters + const decoded = decodeTransaction(JSON.stringify(transferFromABI), calldata); + should.equal(decoded.args[0].toLowerCase(), owner.toLowerCase()); + should.equal(decoded.args[1].toLowerCase(), recipient.toLowerCase()); + should.equal(decoded.args[2].toString(), tokenId); + }); + + it('should not include bytes data parameter in transferFrom calldata', () => { + const builder = new ERC721TransferBuilder(); + builder.tokenContractAddress(htsNftContractAddress).to(recipient).from(owner).tokenId(tokenId); + + const transferFromData = builder.buildTransferFrom(); + const safeTransferData = builder.build(); + + // transferFrom has 3 params (from, to, tokenId), safeTransferFrom has 4 (+ bytes data) + // So transferFrom encoding should be shorter + transferFromData.length.should.be.lessThan(safeTransferData.length); + + // Verify safeTransferFrom uses 0xb88d4fde + safeTransferData.should.startWith('0xb88d4fde'); + }); +}); + function decodeTransaction(abi: string, calldata: string) { const contractInterface = new ethers.utils.Interface(abi); return contractInterface.parseTransaction({ data: calldata }); diff --git a/modules/sdk-coin-evm/src/evmCoin.ts b/modules/sdk-coin-evm/src/evmCoin.ts index f6e0fa968a..651b80116a 100644 --- a/modules/sdk-coin-evm/src/evmCoin.ts +++ b/modules/sdk-coin-evm/src/evmCoin.ts @@ -1,10 +1,19 @@ /** * @prettier */ -import { BaseCoin, BitGoBase, common, MPCAlgorithm, MultisigType, multisigTypes } from '@bitgo/sdk-core'; +import { + BaseCoin, + BitGoBase, + BuildNftTransferDataOptions, + common, + MPCAlgorithm, + MultisigType, + multisigTypes, +} from '@bitgo/sdk-core'; import { BaseCoin as StaticsBaseCoin, CoinFeature, coins, CoinFamily } from '@bitgo/statics'; import { AbstractEthLikeNewCoins, + ERC721TransferBuilder, OfflineVaultTxInfo, RecoverOptions, recoveryBlockchainExplorerQuery, @@ -13,7 +22,7 @@ import { VerifyEthTransactionOptions, } from '@bitgo/abstract-eth'; import { TransactionBuilder } from './lib'; -import { recovery_HBAREVM_BlockchainExplorerQuery, validateHederaAccountId } from './lib/utils'; +import { isHtsEvmAddress, recovery_HBAREVM_BlockchainExplorerQuery, validateHederaAccountId } from './lib/utils'; import assert from 'assert'; export class EvmCoin extends AbstractEthLikeNewCoins { @@ -135,4 +144,17 @@ export class EvmCoin extends AbstractEthLikeNewCoins { } return super.isValidAddress(address); } + + /** @inheritDoc */ + buildNftTransferData(params: BuildNftTransferDataOptions): string { + if (params.type === 'ERC721' && isHtsEvmAddress(params.tokenContractAddress)) { + return new ERC721TransferBuilder() + .tokenContractAddress(params.tokenContractAddress) + .to(params.recipientAddress) + .from(params.fromAddress) + .tokenId(params.tokenId) + .buildTransferFrom(); + } + return super.buildNftTransferData(params); + } } diff --git a/modules/sdk-coin-evm/src/lib/utils.ts b/modules/sdk-coin-evm/src/lib/utils.ts index ff934e9861..d9935e804f 100644 --- a/modules/sdk-coin-evm/src/lib/utils.ts +++ b/modules/sdk-coin-evm/src/lib/utils.ts @@ -224,6 +224,18 @@ async function getGasLimitFromRPC(query: Record, rpcUrl: string) return response.body; } +/** + * Check if an EVM address is an HTS (Hedera Token Service) native address. + * HTS entities on Hedera EVM use "long-zero" addresses where the first 12 bytes are all zeros + * and the entity number occupies the last 8 bytes (e.g. 0x00000000000000000000000000000000007ac203). + * Standard Solidity contracts have normal EVM addresses derived from public key hashes. + */ +export function isHtsEvmAddress(address: string): boolean { + const normalized = address.toLowerCase(); + // First 12 bytes (24 hex chars) after '0x' prefix are all zeros + return /^0x0{24}[0-9a-f]{16}$/.test(normalized); +} + export function validateHederaAccountId(address: string): { valid: boolean; error: string | null } { const parts = address.split('.'); if (parts.length !== 3) { diff --git a/modules/sdk-coin-evm/test/unit/utils.ts b/modules/sdk-coin-evm/test/unit/utils.ts index d64b7602df..27f5e92358 100644 --- a/modules/sdk-coin-evm/test/unit/utils.ts +++ b/modules/sdk-coin-evm/test/unit/utils.ts @@ -1,9 +1,35 @@ import nock from 'nock'; import 'should'; -import { recovery_HBAREVM_BlockchainExplorerQuery } from '../../src/lib/utils'; +import { isHtsEvmAddress, recovery_HBAREVM_BlockchainExplorerQuery } from '../../src/lib/utils'; describe('EVM Coin Utils', function () { + describe('isHtsEvmAddress', () => { + it('should return true for HTS native token addresses (long-zero format)', () => { + isHtsEvmAddress('0x00000000000000000000000000000000007ac203').should.be.true(); + isHtsEvmAddress('0x00000000000000000000000000000000007103a5').should.be.true(); + isHtsEvmAddress('0x0000000000000000000000000000000000728a62').should.be.true(); + isHtsEvmAddress('0x00000000000000000000000000000000007ac19c').should.be.true(); + }); + + it('should return false for standard Solidity contract addresses', () => { + isHtsEvmAddress('0x5df4076613e714a4cc4284abac87caa927b918a8').should.be.false(); + isHtsEvmAddress('0xcee79325714727016c125f80ef1a5d1f47b3d8d2').should.be.false(); + isHtsEvmAddress('0xc795c4faae7f16a69bec13c5dfd9e8a156a68625').should.be.false(); + isHtsEvmAddress('0x8f977e912ef500548a0c3be6ddde9899f1199b81').should.be.false(); + }); + + it('should handle uppercase hex characters', () => { + isHtsEvmAddress('0x00000000000000000000000000000000007AC203').should.be.true(); + isHtsEvmAddress('0x5DF4076613E714A4CC4284ABAC87CAA927B918A8').should.be.false(); + }); + + it('should return false for invalid format', () => { + isHtsEvmAddress('0x1234').should.be.false(); + isHtsEvmAddress('not-an-address').should.be.false(); + }); + }); + describe('recovery_HBAREVM_BlockchainExplorerQuery', function () { const mockRpcUrl = 'https://testnet.hashio.io/api'; const mockExplorerUrl = 'https://testnet.mirrornode.hedera.com/api/v1';