Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()) {
Expand Down
56 changes: 39 additions & 17 deletions modules/abstract-eth/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import {
ERC1155SafeTransferTypes,
ERC721SafeTransferTypeMethodId,
ERC721SafeTransferTypes,
ERC721TransferFromMethodId,
ERC721TransferFromTypes,
flushCoinsMethodId,
flushCoinsTypes,
flushForwarderTokensMethodId,
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions modules/abstract-eth/src/lib/walletUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'];
Expand Down
40 changes: 40 additions & 0 deletions modules/abstract-eth/test/unit/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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';
Expand Down
54 changes: 54 additions & 0 deletions modules/sdk-coin-eth/test/unit/transactionBuilder/sendNFT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
26 changes: 24 additions & 2 deletions modules/sdk-coin-evm/src/evmCoin.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}
}
12 changes: 12 additions & 0 deletions modules/sdk-coin-evm/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ async function getGasLimitFromRPC(query: Record<string, string>, 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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this address format common for all EVM coins?

Copy link
Copy Markdown
Contributor Author

@rohitsaw115 rohitsaw115 Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it is only for hedera native token, which is compatiable with erc20/erc721 standard but has different address format, other evm chain has address derrived from deployer Address and salt.

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) {
Expand Down
28 changes: 27 additions & 1 deletion modules/sdk-coin-evm/test/unit/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading