Skip to content
Draft
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
10 changes: 7 additions & 3 deletions modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
getProxyInitcode,
getRawDecoded,
getToken,
isHtsEvmAddress,
KeyPair as KeyPairLib,
TransactionBuilder,
TransferBuilder,
Expand Down Expand Up @@ -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;
}

Expand Down
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
68 changes: 51 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 @@ -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
*/
Expand Down Expand Up @@ -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 {
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
72 changes: 72 additions & 0 deletions modules/abstract-eth/test/unit/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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';
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
Loading