Skip to content

Commit c19facc

Browse files
committed
feat: hts nft transfer support
ticket: cecho-39
1 parent 190b1d0 commit c19facc

File tree

9 files changed

+220
-23
lines changed

9 files changed

+220
-23
lines changed

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3415,13 +3415,12 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
34153415
switch (params.type) {
34163416
case 'ERC721': {
34173417
const tokenId = params.tokenId;
3418-
const contractData = new ERC721TransferBuilder()
3418+
return new ERC721TransferBuilder()
34193419
.tokenContractAddress(tokenContractAddress)
34203420
.to(recipientAddress)
34213421
.from(fromAddress)
34223422
.tokenId(tokenId)
34233423
.build();
3424-
return contractData;
34253424
}
34263425

34273426
case 'ERC1155': {

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: 39 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,
@@ -509,26 +511,46 @@ export function decodeERC721TransferData(data: string): ERC721TransferData {
509511
);
510512

511513
const internalDataHex = bufferToHex(internalData as Buffer);
512-
if (!internalDataHex.startsWith(ERC721SafeTransferTypeMethodId)) {
513-
throw new BuildTransactionError(`Invalid transfer bytecode: ${data}`);
514+
515+
if (internalDataHex.startsWith(ERC721SafeTransferTypeMethodId)) {
516+
const [from, receiver, tokenId, userSentData] = getRawDecoded(
517+
ERC721SafeTransferTypes,
518+
getBufferedByteCode(ERC721SafeTransferTypeMethodId, internalDataHex)
519+
);
520+
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+
};
514532
}
515533

516-
const [from, receiver, tokenId, userSentData] = getRawDecoded(
517-
ERC721SafeTransferTypes,
518-
getBufferedByteCode(ERC721SafeTransferTypeMethodId, internalDataHex)
519-
);
534+
if (internalDataHex.startsWith(ERC721TransferFromMethodId)) {
535+
const [from, receiver, tokenId] = getRawDecoded(
536+
ERC721TransferFromTypes,
537+
getBufferedByteCode(ERC721TransferFromMethodId, internalDataHex)
538+
);
520539

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-
};
540+
return {
541+
to: addHexPrefix(receiver as string),
542+
from: addHexPrefix(from as string),
543+
expireTime: bufferToInt(expireTime as Buffer),
544+
amount: new BigNumber(bufferToHex(amount as Buffer)).toFixed(),
545+
tokenId: new BigNumber(bufferToHex(tokenId as Buffer)).toFixed(),
546+
sequenceId: bufferToInt(sequenceId as Buffer),
547+
signature: bufferToHex(signature as Buffer),
548+
tokenContractAddress: addHexPrefix(to as string),
549+
userData: '',
550+
};
551+
}
552+
553+
throw new BuildTransactionError(`Invalid transfer bytecode: ${data}`);
532554
}
533555

534556
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
decodeFlushERC721TokensData,
66
decodeFlushERC1155TokensData,
77
} from '../../src/lib/utils';
8+
import { ERC721TransferBuilder } from '../../src/lib/transferBuilders/transferBuilderERC721';
9+
import { ERC721TransferFromMethodId, ERC721SafeTransferTypeMethodId } from '../../src/lib/walletUtil';
810

911
describe('Abstract ETH Utils', () => {
1012
describe('ERC721 Flush Functions', () => {
@@ -209,6 +211,44 @@ describe('Abstract ETH Utils', () => {
209211
});
210212
});
211213

214+
describe('ERC721TransferBuilder.buildTransferFrom', () => {
215+
const owner = '0x19645032c7f1533395d44a629462e751084d3e4d';
216+
const recipient = '0x19645032c7f1533395d44a629462e751084d3e4c';
217+
const htsNftAddress = '0x00000000000000000000000000000000007ac203';
218+
219+
it('should encode transferFrom with selector 0x23b872dd', () => {
220+
const builder = new ERC721TransferBuilder();
221+
builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12');
222+
223+
const data = builder.buildTransferFrom();
224+
225+
should.exist(data);
226+
data.should.startWith(ERC721TransferFromMethodId); // 0x23b872dd
227+
});
228+
229+
it('should encode safeTransferFrom with selector 0xb88d4fde via build()', () => {
230+
const builder = new ERC721TransferBuilder();
231+
builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12');
232+
233+
const data = builder.build();
234+
235+
should.exist(data);
236+
data.should.startWith(ERC721SafeTransferTypeMethodId); // 0xb88d4fde
237+
});
238+
239+
it('should produce different encodings for build() vs buildTransferFrom()', () => {
240+
const builder = new ERC721TransferBuilder();
241+
builder.tokenContractAddress(htsNftAddress).to(recipient).from(owner).tokenId('12');
242+
243+
const safeTransferData = builder.build();
244+
const transferFromData = builder.buildTransferFrom();
245+
246+
safeTransferData.should.not.equal(transferFromData);
247+
// transferFrom encoding should be shorter (no bytes param)
248+
transferFromData.length.should.be.lessThan(safeTransferData.length);
249+
});
250+
});
251+
212252
describe('Token Address Validation', () => {
213253
it('should preserve address format in encoding/decoding', () => {
214254
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 });

modules/sdk-coin-evm/src/evmCoin.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
/**
22
* @prettier
33
*/
4-
import { BaseCoin, BitGoBase, common, MPCAlgorithm, MultisigType, multisigTypes } from '@bitgo/sdk-core';
4+
import {
5+
BaseCoin,
6+
BitGoBase,
7+
BuildNftTransferDataOptions,
8+
common,
9+
MPCAlgorithm,
10+
MultisigType,
11+
multisigTypes,
12+
} from '@bitgo/sdk-core';
513
import { BaseCoin as StaticsBaseCoin, CoinFeature, coins, CoinFamily } from '@bitgo/statics';
614
import {
715
AbstractEthLikeNewCoins,
16+
ERC721TransferBuilder,
817
OfflineVaultTxInfo,
918
RecoverOptions,
1019
recoveryBlockchainExplorerQuery,
@@ -13,7 +22,7 @@ import {
1322
VerifyEthTransactionOptions,
1423
} from '@bitgo/abstract-eth';
1524
import { TransactionBuilder } from './lib';
16-
import { recovery_HBAREVM_BlockchainExplorerQuery, validateHederaAccountId } from './lib/utils';
25+
import { isHtsEvmAddress, recovery_HBAREVM_BlockchainExplorerQuery, validateHederaAccountId } from './lib/utils';
1726
import assert from 'assert';
1827

1928
export class EvmCoin extends AbstractEthLikeNewCoins {
@@ -135,4 +144,21 @@ export class EvmCoin extends AbstractEthLikeNewCoins {
135144
}
136145
return super.isValidAddress(address);
137146
}
147+
148+
/** @inheritDoc */
149+
buildNftTransferData(params: BuildNftTransferDataOptions): string {
150+
if (
151+
params.type === 'ERC721' &&
152+
this.getFamily() === CoinFamily.HBAREVM &&
153+
isHtsEvmAddress(params.tokenContractAddress)
154+
) {
155+
return new ERC721TransferBuilder()
156+
.tokenContractAddress(params.tokenContractAddress)
157+
.to(params.recipientAddress)
158+
.from(params.fromAddress)
159+
.tokenId(params.tokenId)
160+
.buildTransferFrom();
161+
}
162+
return super.buildNftTransferData(params);
163+
}
138164
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,18 @@ async function getGasLimitFromRPC(query: Record<string, string>, rpcUrl: string)
224224
return response.body;
225225
}
226226

227+
/**
228+
* Check if an EVM address is an HTS (Hedera Token Service) native address.
229+
* HTS entities on Hedera EVM use "long-zero" addresses where the first 12 bytes are all zeros
230+
* and the entity number occupies the last 8 bytes (e.g. 0x00000000000000000000000000000000007ac203).
231+
* Standard Solidity contracts have normal EVM addresses derived from public key hashes.
232+
*/
233+
export function isHtsEvmAddress(address: string): boolean {
234+
const normalized = address.toLowerCase();
235+
// First 12 bytes (24 hex chars) after '0x' prefix are all zeros
236+
return /^0x0{24}[0-9a-f]{16}$/.test(normalized);
237+
}
238+
227239
export function validateHederaAccountId(address: string): { valid: boolean; error: string | null } {
228240
const parts = address.split('.');
229241
if (parts.length !== 3) {

modules/sdk-coin-evm/test/unit/utils.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
import nock from 'nock';
22
import 'should';
33

4-
import { recovery_HBAREVM_BlockchainExplorerQuery } from '../../src/lib/utils';
4+
import { isHtsEvmAddress, recovery_HBAREVM_BlockchainExplorerQuery } from '../../src/lib/utils';
55

66
describe('EVM Coin Utils', function () {
7+
describe('isHtsEvmAddress', () => {
8+
it('should return true for HTS native token addresses (long-zero format)', () => {
9+
isHtsEvmAddress('0x00000000000000000000000000000000007ac203').should.be.true();
10+
isHtsEvmAddress('0x00000000000000000000000000000000007103a5').should.be.true();
11+
isHtsEvmAddress('0x0000000000000000000000000000000000728a62').should.be.true();
12+
isHtsEvmAddress('0x00000000000000000000000000000000007ac19c').should.be.true();
13+
});
14+
15+
it('should return false for standard Solidity contract addresses', () => {
16+
isHtsEvmAddress('0x5df4076613e714a4cc4284abac87caa927b918a8').should.be.false();
17+
isHtsEvmAddress('0xcee79325714727016c125f80ef1a5d1f47b3d8d2').should.be.false();
18+
isHtsEvmAddress('0xc795c4faae7f16a69bec13c5dfd9e8a156a68625').should.be.false();
19+
isHtsEvmAddress('0x8f977e912ef500548a0c3be6ddde9899f1199b81').should.be.false();
20+
});
21+
22+
it('should handle uppercase hex characters', () => {
23+
isHtsEvmAddress('0x00000000000000000000000000000000007AC203').should.be.true();
24+
isHtsEvmAddress('0x5DF4076613E714A4CC4284ABAC87CAA927B918A8').should.be.false();
25+
});
26+
27+
it('should return false for invalid format', () => {
28+
isHtsEvmAddress('0x1234').should.be.false();
29+
isHtsEvmAddress('not-an-address').should.be.false();
30+
});
31+
});
32+
733
describe('recovery_HBAREVM_BlockchainExplorerQuery', function () {
834
const mockRpcUrl = 'https://testnet.hashio.io/api';
935
const mockExplorerUrl = 'https://testnet.mirrornode.hedera.com/api/v1';

0 commit comments

Comments
 (0)