From da9e28d982a2f8f847c1bc864a58405cb3ee0868 Mon Sep 17 00:00:00 2001 From: Rohit Saw Date: Fri, 10 Apr 2026 11:27:34 +0530 Subject: [PATCH] feat: HTS NFT support ticket: cecho-709 --- .../src/abstractEthLikeNewCoins.ts | 10 ++- .../transferBuilders/transferBuilderERC721.ts | 18 ++++- modules/abstract-eth/src/lib/utils.ts | 68 +++++++++++++----- modules/abstract-eth/src/lib/walletUtil.ts | 2 + modules/abstract-eth/test/unit/utils.ts | 72 +++++++++++++++++++ .../test/unit/transactionBuilder/sendNFT.ts | 54 ++++++++++++++ 6 files changed, 203 insertions(+), 21 deletions(-) diff --git a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts index 7f830bd1a9..ce7315fe15 100644 --- a/modules/abstract-eth/src/abstractEthLikeNewCoins.ts +++ b/modules/abstract-eth/src/abstractEthLikeNewCoins.ts @@ -82,6 +82,7 @@ import { getProxyInitcode, getRawDecoded, getToken, + isHtsEvmAddress, KeyPair as KeyPairLib, TransactionBuilder, TransferBuilder, @@ -3415,12 +3416,15 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin { switch (params.type) { case 'ERC721': { const tokenId = params.tokenId; - const contractData = new ERC721TransferBuilder() + const builder = new ERC721TransferBuilder() .tokenContractAddress(tokenContractAddress) .to(recipientAddress) .from(fromAddress) - .tokenId(tokenId) - .build(); + .tokenId(tokenId); + // HTS native NFTs on Hedera EVM only support transferFrom(address,address,uint256). + // Standard Solidity ERC721 contracts on hbarevm still use safeTransferFrom. + const isHtsNft = this.getFamily() === 'hbarevm' && isHtsEvmAddress(tokenContractAddress); + const contractData = isHtsNft ? builder.buildTransferFrom() : builder.build(); return contractData; } 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..61f3b02a77 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, @@ -84,6 +86,18 @@ import { } from './walletUtil'; import { EthTransactionData } from './types'; +/** + * 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); +} + /** * @param network */ @@ -509,26 +523,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..c935f14bac 100644 --- a/modules/abstract-eth/test/unit/utils.ts +++ b/modules/abstract-eth/test/unit/utils.ts @@ -4,7 +4,10 @@ import { flushERC1155TokensData, decodeFlushERC721TokensData, decodeFlushERC1155TokensData, + isHtsEvmAddress, } 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 +212,75 @@ describe('Abstract ETH Utils', () => { }); }); + describe('isHtsEvmAddress', () => { + it('should return true for HTS native token addresses (long-zero format)', () => { + // Real HTS NFT addresses from statics + 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 zero address', () => { + isHtsEvmAddress('0x0000000000000000000000000000000000000000').should.be.true(); + }); + + it('should return false for invalid format', () => { + isHtsEvmAddress('0x1234').should.be.false(); + isHtsEvmAddress('not-an-address').should.be.false(); + }); + }); + + 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 });