Skip to content

Commit 68f5a03

Browse files
committed
feat: hts nft support
ticket: cecho-39
1 parent 70ddda3 commit 68f5a03

File tree

6 files changed

+203
-21
lines changed

6 files changed

+203
-21
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
getProxyInitcode,
8383
getRawDecoded,
8484
getToken,
85+
isHtsEvmAddress,
8586
KeyPair as KeyPairLib,
8687
TransactionBuilder,
8788
TransferBuilder,
@@ -3415,12 +3416,15 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
34153416
switch (params.type) {
34163417
case 'ERC721': {
34173418
const tokenId = params.tokenId;
3418-
const contractData = new ERC721TransferBuilder()
3419+
const builder = new ERC721TransferBuilder()
34193420
.tokenContractAddress(tokenContractAddress)
34203421
.to(recipientAddress)
34213422
.from(fromAddress)
3422-
.tokenId(tokenId)
3423-
.build();
3423+
.tokenId(tokenId);
3424+
// HTS native NFTs on Hedera EVM only support transferFrom(address,address,uint256).
3425+
// Standard Solidity ERC721 contracts on hbarevm still use safeTransferFrom.
3426+
const isHtsNft = this.getFamily() === 'hbarevm' && isHtsEvmAddress(tokenContractAddress);
3427+
const contractData = isHtsNft ? builder.buildTransferFrom() : builder.build();
34243428
return contractData;
34253429
}
34263430

modules/abstract-eth/src/lib/transferBuilders/transferBuilderERC721.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { hexlify, hexZeroPad } from 'ethers/lib/utils';
44
import { ContractCall } from '../contractCall';
55
import { decodeERC721TransferData, isValidEthAddress, sendMultiSigData } from '../utils';
66
import { BaseNFTTransferBuilder } from './baseNFTTransferBuilder';
7-
import { ERC721SafeTransferTypeMethodId, ERC721SafeTransferTypes } from '../walletUtil';
7+
import {
8+
ERC721SafeTransferTypeMethodId,
9+
ERC721SafeTransferTypes,
10+
ERC721TransferFromMethodId,
11+
ERC721TransferFromTypes,
12+
} from '../walletUtil';
813
import { coins, EthereumNetwork as EthLikeNetwork } from '@bitgo/statics';
914

1015
export class ERC721TransferBuilder extends BaseNFTTransferBuilder {
@@ -54,6 +59,17 @@ export class ERC721TransferBuilder extends BaseNFTTransferBuilder {
5459
return contractCall.serialize();
5560
}
5661

62+
/**
63+
* Build using transferFrom(address,address,uint256) without the bytes data parameter.
64+
* Required for HTS NFT transfers on Hedera EVM, which only supports transferFrom.
65+
*/
66+
buildTransferFrom(): string {
67+
const types = ERC721TransferFromTypes;
68+
const values = [this._fromAddress, this._toAddress, this._tokenId];
69+
const contractCall = new ContractCall(ERC721TransferFromMethodId, types, values);
70+
return contractCall.serialize();
71+
}
72+
5773
signAndBuild(chainId: string): string {
5874
this._chainId = chainId;
5975
if (this.hasMandatoryFields()) {

modules/abstract-eth/src/lib/utils.ts

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ import {
5151
ERC1155SafeTransferTypes,
5252
ERC721SafeTransferTypeMethodId,
5353
ERC721SafeTransferTypes,
54+
ERC721TransferFromMethodId,
55+
ERC721TransferFromTypes,
5456
flushCoinsMethodId,
5557
flushCoinsTypes,
5658
flushForwarderTokensMethodId,
@@ -84,6 +86,18 @@ import {
8486
} from './walletUtil';
8587
import { EthTransactionData } from './types';
8688

89+
/**
90+
* Check if an EVM address is an HTS (Hedera Token Service) native address.
91+
* HTS entities on Hedera EVM use "long-zero" addresses where the first 12 bytes are all zeros
92+
* and the entity number occupies the last 8 bytes (e.g. 0x00000000000000000000000000000000007ac203).
93+
* Standard Solidity contracts have normal EVM addresses derived from public key hashes.
94+
*/
95+
export function isHtsEvmAddress(address: string): boolean {
96+
const normalized = address.toLowerCase();
97+
// First 12 bytes (24 hex chars) after '0x' prefix are all zeros
98+
return /^0x0{24}[0-9a-f]{16}$/.test(normalized);
99+
}
100+
87101
/**
88102
* @param network
89103
*/
@@ -509,26 +523,46 @@ export function decodeERC721TransferData(data: string): ERC721TransferData {
509523
);
510524

511525
const internalDataHex = bufferToHex(internalData as Buffer);
512-
if (!internalDataHex.startsWith(ERC721SafeTransferTypeMethodId)) {
513-
throw new BuildTransactionError(`Invalid transfer bytecode: ${data}`);
526+
527+
if (internalDataHex.startsWith(ERC721SafeTransferTypeMethodId)) {
528+
const [from, receiver, tokenId, userSentData] = getRawDecoded(
529+
ERC721SafeTransferTypes,
530+
getBufferedByteCode(ERC721SafeTransferTypeMethodId, internalDataHex)
531+
);
532+
533+
return {
534+
to: addHexPrefix(receiver as string),
535+
from: addHexPrefix(from as string),
536+
expireTime: bufferToInt(expireTime as Buffer),
537+
amount: new BigNumber(bufferToHex(amount as Buffer)).toFixed(),
538+
tokenId: new BigNumber(bufferToHex(tokenId as Buffer)).toFixed(),
539+
sequenceId: bufferToInt(sequenceId as Buffer),
540+
signature: bufferToHex(signature as Buffer),
541+
tokenContractAddress: addHexPrefix(to as string),
542+
userData: bufferToHex(userSentData as Buffer),
543+
};
514544
}
515545

516-
const [from, receiver, tokenId, userSentData] = getRawDecoded(
517-
ERC721SafeTransferTypes,
518-
getBufferedByteCode(ERC721SafeTransferTypeMethodId, internalDataHex)
519-
);
546+
if (internalDataHex.startsWith(ERC721TransferFromMethodId)) {
547+
const [from, receiver, tokenId] = getRawDecoded(
548+
ERC721TransferFromTypes,
549+
getBufferedByteCode(ERC721TransferFromMethodId, internalDataHex)
550+
);
520551

521-
return {
522-
to: addHexPrefix(receiver as string),
523-
from: addHexPrefix(from as string),
524-
expireTime: bufferToInt(expireTime as Buffer),
525-
amount: new BigNumber(bufferToHex(amount as Buffer)).toFixed(),
526-
tokenId: new BigNumber(bufferToHex(tokenId as Buffer)).toFixed(),
527-
sequenceId: bufferToInt(sequenceId as Buffer),
528-
signature: bufferToHex(signature as Buffer),
529-
tokenContractAddress: addHexPrefix(to as string),
530-
userData: bufferToHex(userSentData as Buffer),
531-
};
552+
return {
553+
to: addHexPrefix(receiver as string),
554+
from: addHexPrefix(from as string),
555+
expireTime: bufferToInt(expireTime as Buffer),
556+
amount: new BigNumber(bufferToHex(amount as Buffer)).toFixed(),
557+
tokenId: new BigNumber(bufferToHex(tokenId as Buffer)).toFixed(),
558+
sequenceId: bufferToInt(sequenceId as Buffer),
559+
signature: bufferToHex(signature as Buffer),
560+
tokenContractAddress: addHexPrefix(to as string),
561+
userData: '',
562+
};
563+
}
564+
565+
throw new BuildTransactionError(`Invalid transfer bytecode: ${data}`);
532566
}
533567

534568
export function decodeERC1155TransferData(data: string): ERC1155TransferData {

modules/abstract-eth/src/lib/walletUtil.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const flushERC1155ForwarderTokensMethodId = '0xe6bd0aa4';
1616
export const flushERC1155ForwarderTokensMethodIdV4 = '0x8972c17c';
1717

1818
export const ERC721SafeTransferTypeMethodId = '0xb88d4fde';
19+
export const ERC721TransferFromMethodId = '0x23b872dd';
1920
export const ERC1155SafeTransferTypeMethodId = '0xf242432a';
2021
export const ERC1155BatchTransferTypeMethodId = '0x2eb2c2d6';
2122
export const defaultForwarderVersion = 0;
@@ -38,6 +39,7 @@ export const sendMultiSigTokenTypes = ['address', 'uint', 'address', 'uint', 'ui
3839
export const sendMultiSigTokenTypesFirstSigner = ['string', 'address', 'uint', 'address', 'uint', 'uint'];
3940

4041
export const ERC721SafeTransferTypes = ['address', 'address', 'uint256', 'bytes'];
42+
export const ERC721TransferFromTypes = ['address', 'address', 'uint256'];
4143

4244
export const ERC1155SafeTransferTypes = ['address', 'address', 'uint256', 'uint256', 'bytes'];
4345
export const ERC1155BatchTransferTypes = ['address', 'address', 'uint256[]', 'uint256[]', 'bytes'];

modules/abstract-eth/test/unit/utils.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
flushERC1155TokensData,
55
decodeFlushERC721TokensData,
66
decodeFlushERC1155TokensData,
7+
isHtsEvmAddress,
78
} from '../../src/lib/utils';
9+
import { ERC721TransferBuilder } from '../../src/lib/transferBuilders/transferBuilderERC721';
10+
import { ERC721TransferFromMethodId, ERC721SafeTransferTypeMethodId } from '../../src/lib/walletUtil';
811

912
describe('Abstract ETH Utils', () => {
1013
describe('ERC721 Flush Functions', () => {
@@ -209,6 +212,75 @@ describe('Abstract ETH Utils', () => {
209212
});
210213
});
211214

215+
describe('isHtsEvmAddress', () => {
216+
it('should return true for HTS native token addresses (long-zero format)', () => {
217+
// Real HTS NFT addresses from statics
218+
isHtsEvmAddress('0x00000000000000000000000000000000007ac203').should.be.true();
219+
isHtsEvmAddress('0x00000000000000000000000000000000007103a5').should.be.true();
220+
isHtsEvmAddress('0x0000000000000000000000000000000000728a62').should.be.true();
221+
isHtsEvmAddress('0x00000000000000000000000000000000007ac19c').should.be.true();
222+
});
223+
224+
it('should return false for standard Solidity contract addresses', () => {
225+
isHtsEvmAddress('0x5df4076613e714a4cc4284abac87caa927b918a8').should.be.false();
226+
isHtsEvmAddress('0xcee79325714727016c125f80ef1a5d1f47b3d8d2').should.be.false();
227+
isHtsEvmAddress('0xc795c4faae7f16a69bec13c5dfd9e8a156a68625').should.be.false();
228+
isHtsEvmAddress('0x8f977e912ef500548a0c3be6ddde9899f1199b81').should.be.false();
229+
});
230+
231+
it('should handle uppercase hex characters', () => {
232+
isHtsEvmAddress('0x00000000000000000000000000000000007AC203').should.be.true();
233+
isHtsEvmAddress('0x5DF4076613E714A4CC4284ABAC87CAA927B918A8').should.be.false();
234+
});
235+
236+
it('should return false for zero address', () => {
237+
isHtsEvmAddress('0x0000000000000000000000000000000000000000').should.be.true();
238+
});
239+
240+
it('should return false for invalid format', () => {
241+
isHtsEvmAddress('0x1234').should.be.false();
242+
isHtsEvmAddress('not-an-address').should.be.false();
243+
});
244+
});
245+
246+
describe('ERC721TransferBuilder.buildTransferFrom', () => {
247+
const owner = '0x19645032c7f1533395d44a629462e751084d3e4d';
248+
const recipient = '0x19645032c7f1533395d44a629462e751084d3e4c';
249+
const htsNftAddress = '0x00000000000000000000000000000000007ac203';
250+
251+
it('should encode transferFrom with selector 0x23b872dd', () => {
252+
const builder = new ERC721TransferBuilder();
253+
builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12');
254+
255+
const data = builder.buildTransferFrom();
256+
257+
should.exist(data);
258+
data.should.startWith(ERC721TransferFromMethodId); // 0x23b872dd
259+
});
260+
261+
it('should encode safeTransferFrom with selector 0xb88d4fde via build()', () => {
262+
const builder = new ERC721TransferBuilder();
263+
builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12');
264+
265+
const data = builder.build();
266+
267+
should.exist(data);
268+
data.should.startWith(ERC721SafeTransferTypeMethodId); // 0xb88d4fde
269+
});
270+
271+
it('should produce different encodings for build() vs buildTransferFrom()', () => {
272+
const builder = new ERC721TransferBuilder();
273+
builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12');
274+
275+
const safeTransferData = builder.build();
276+
const transferFromData = builder.buildTransferFrom();
277+
278+
safeTransferData.should.not.equal(transferFromData);
279+
// transferFrom encoding should be shorter (no bytes param)
280+
transferFromData.length.should.be.lessThan(safeTransferData.length);
281+
});
282+
});
283+
212284
describe('Token Address Validation', () => {
213285
it('should preserve address format in encoding/decoding', () => {
214286
const forwarderAddress = '0x8f977e912ef500548a0c3be6ddde9899f1199b81';

modules/sdk-coin-eth/test/unit/transactionBuilder/sendNFT.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,60 @@ describe('Eth transaction builder sendNFT', () => {
266266
});
267267
});
268268

269+
// ABI for transferFrom(address,address,uint256) used by HTS native NFTs on Hedera EVM
270+
const transferFromABI = [
271+
{
272+
inputs: [
273+
{ internalType: 'address', name: 'from', type: 'address' },
274+
{ internalType: 'address', name: 'to', type: 'address' },
275+
{ internalType: 'uint256', name: 'tokenId', type: 'uint256' },
276+
],
277+
name: 'transferFrom',
278+
outputs: [],
279+
stateMutability: 'nonpayable',
280+
type: 'function',
281+
},
282+
];
283+
284+
describe('ERC721 HTS native NFT transferFrom', () => {
285+
const owner = '0x19645032c7f1533395d44a629462e751084d3e4d';
286+
const recipient = '0x19645032c7f1533395d44a629462e751084d3e4c';
287+
// HTS native NFT address (long-zero format)
288+
const htsNftContractAddress = '0x00000000000000000000000000000000007ac203';
289+
const tokenId = '12';
290+
291+
it('should build ERC721 transferFrom calldata with correct selector and params', () => {
292+
const builder = new ERC721TransferBuilder();
293+
builder.tokenContractAddress(htsNftContractAddress).to(recipient).from(owner).tokenId(tokenId);
294+
295+
const calldata = builder.buildTransferFrom();
296+
297+
// Should use transferFrom selector 0x23b872dd
298+
calldata.should.startWith('0x23b872dd');
299+
300+
// Decode and verify parameters
301+
const decoded = decodeTransaction(JSON.stringify(transferFromABI), calldata);
302+
should.equal(decoded.args[0].toLowerCase(), owner.toLowerCase());
303+
should.equal(decoded.args[1].toLowerCase(), recipient.toLowerCase());
304+
should.equal(decoded.args[2].toString(), tokenId);
305+
});
306+
307+
it('should not include bytes data parameter in transferFrom calldata', () => {
308+
const builder = new ERC721TransferBuilder();
309+
builder.tokenContractAddress(htsNftContractAddress).to(recipient).from(owner).tokenId(tokenId);
310+
311+
const transferFromData = builder.buildTransferFrom();
312+
const safeTransferData = builder.build();
313+
314+
// transferFrom has 3 params (from, to, tokenId), safeTransferFrom has 4 (+ bytes data)
315+
// So transferFrom encoding should be shorter
316+
transferFromData.length.should.be.lessThan(safeTransferData.length);
317+
318+
// Verify safeTransferFrom uses 0xb88d4fde
319+
safeTransferData.should.startWith('0xb88d4fde');
320+
});
321+
});
322+
269323
function decodeTransaction(abi: string, calldata: string) {
270324
const contractInterface = new ethers.utils.Interface(abi);
271325
return contractInterface.parseTransaction({ data: calldata });

0 commit comments

Comments
 (0)