From 8e6c52c855a3eccaddf55fd7a258a30d3a763c83 Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Thu, 2 Jan 2025 14:03:52 +0800 Subject: [PATCH 01/11] optimise type --- networks/ethereum/devnet/__tests__/ethers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/networks/ethereum/devnet/__tests__/ethers.test.ts b/networks/ethereum/devnet/__tests__/ethers.test.ts index b73f6e13..dc9736b5 100644 --- a/networks/ethereum/devnet/__tests__/ethers.test.ts +++ b/networks/ethereum/devnet/__tests__/ethers.test.ts @@ -59,7 +59,7 @@ describe('ETH Transfer Test', () => { const initialSenderBalance = await usdtContract.balanceOf(walletSender.address); const initialReceiverBalance = await usdtContract.balanceOf(walletReceiver.address); - const tx = await (usdtContract as any).connect(walletSender).transfer(walletReceiver.address, amountToSend); + const tx = await (usdtContract.connect(walletSender) as ethers.Contract).transfer(walletReceiver.address, amountToSend); await tx.wait(); const finalSenderBalance = await usdtContract.balanceOf(walletSender.address); From e64a7179d5077515f8c44772a11cc2bb32c13cac Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Tue, 7 Jan 2025 00:41:49 +0800 Subject: [PATCH 02/11] send ETH success without ehters.js --- .../devnet/__tests__/noethers.test.ts | 139 ++++++++ networks/ethereum/package.json | 1 + networks/ethereum/src/EthereumTransfer.ts | 326 ++++++++++++++++++ 3 files changed, 466 insertions(+) create mode 100644 networks/ethereum/devnet/__tests__/noethers.test.ts create mode 100644 networks/ethereum/src/EthereumTransfer.ts diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts new file mode 100644 index 00000000..11e057a7 --- /dev/null +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -0,0 +1,139 @@ +// EthereumTransferNoLibV12.legacy.test.ts +import { EthereumTransferNoLibV12 } from '../../src/EthereumTransfer'; +// Adjust the import path as needed +import axios from 'axios'; +import { hexToBytes, bytesToHex } from 'ethereum-cryptography/utils'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { keccak256 } from 'ethereum-cryptography/keccak'; + +// RPC endpoint for your local/test environment. +// E.g., Hardhat node: http://127.0.0.1:8545 +// or a testnet node: https://goerli.infura.io/v3/... +const RPC_URL = 'http://127.0.0.1:8545'; + +// Two example private keys +const privSender = '0x' + '0'.repeat(63) + '1'; +const privReceiver = '0x' + '0'.repeat(63) + '2'; + +/** + * Helper to derive an address from a private key (uncompressed). + */ +function deriveAddressFromPrivateKey(pk: string): string { + const pkBytes = hexToBytes(pk.replace(/^0x/, '')); + const pubUncompressed = secp256k1.getPublicKey(pkBytes, false); // 65 bytes + const pubNoPrefix = pubUncompressed.slice(1); // remove 0x04 + const hash = keccak256(pubNoPrefix); + const addrBytes = hash.slice(-20); + return '0x' + bytesToHex(addrBytes); +} + +/** + * Helper to query ETH balance via JSON-RPC (eth_getBalance). + * Returns a BigInt (balance in wei). + */ +async function getBalance(address: string): Promise { + const payload = { + jsonrpc: '2.0', + method: 'eth_getBalance', + params: [address, 'latest'], + id: 1, + }; + const resp = await axios.post(RPC_URL, payload); + return BigInt(resp.data.result); +} + +describe('EthereumTransferNoLibV12 (Legacy Transaction) Tests', () => { + const senderAddress = deriveAddressFromPrivateKey(privSender); + console.log('Derived senderAddress =', senderAddress); + const receiverAddress = deriveAddressFromPrivateKey(privReceiver); + + // Instance to send from privSender + let transfer: EthereumTransferNoLibV12; + + beforeAll(async () => { + transfer = new EthereumTransferNoLibV12(privSender, RPC_URL); + const chainId = await transfer['getChainId'](); + console.log('chainId from node:', chainId); + }); + + it('should send legacy transaction from sender to receiver, and check balances', async () => { + // 1) Check initial balances + const beforeSenderBalance = await getBalance(senderAddress); + const beforeReceiverBalance = await getBalance(receiverAddress); + + console.log('Sender balance before:', beforeSenderBalance.toString()); + console.log('Receiver balance before:', beforeReceiverBalance.toString()); + + // 2) Prepare legacy transaction fields + // e.g., sending + const valueWei = 10000000000000000n; // 0.01 ETH + const gasLimit = 21000n; // minimal for simple transfer + + // 3) Fetch gas price from the node + const gasPrice = await transfer.getGasPrice(); + console.log('Current gas price:', gasPrice.toString()); + + console.log('gasPrice * gasLimit', gasPrice*BigInt(gasLimit)) + + // **New Code Addition: Print Sender's Balance Before Sending** + // This is to verify the sender's balance right before the transaction + const currentSenderBalance = await getBalance(senderAddress); + console.log('Sender balance right before sending:', currentSenderBalance.toString()); + + // 4) Send legacy transaction + const txHash = await transfer.sendLegacyTransaction( + receiverAddress, + valueWei, + gasPrice, // Convert BigInt to string + gasLimit + ); + expect(txHash).toMatch(/^0x[0-9a-fA-F]+$/); + + console.log('Legacy txHash:', txHash); + + // 5) (Optional) Wait for transaction to be mined + // For local nodes, this might not be necessary as transactions are mined instantly + // For testnets, implement a polling mechanism or use WebSocket subscriptions + // Here, we'll poll for the receipt until it's available + async function getTransactionReceipt(txHash: string): Promise { + while (true) { + const receiptPayload = { + jsonrpc: '2.0', + method: 'eth_getTransactionReceipt', + params: [txHash], + id: 1, + }; + const receiptResp = await axios.post(RPC_URL, receiptPayload); + if (receiptResp.data.result) { + return receiptResp.data.result; + } + // Wait for a short interval before retrying + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + const receipt = await getTransactionReceipt(txHash); + expect(receipt.status).toBe('0x1'); // '0x1' indicates success + + // 6) Check final balances + const afterSenderBalance = await getBalance(senderAddress); + const afterReceiverBalance = await getBalance(receiverAddress); + + console.log('Sender balance after:', afterSenderBalance.toString()); + console.log('Receiver balance after:', afterReceiverBalance.toString()); + + // 7) Validate changes + const senderDelta = beforeSenderBalance - afterSenderBalance; // how much sender lost + const receiverDelta = afterReceiverBalance - beforeReceiverBalance; // how much receiver gained + + console.log('Sender delta:', senderDelta.toString()); + console.log('Receiver delta:', receiverDelta.toString()); + + // The receiver should gain exactly "valueWei" + expect(receiverDelta).toBe(BigInt(valueWei)); + + // The sender should lose at least "valueWei" (plus gas fees). + // So, we just check that the sender's lost amount >= valueWei + expect(senderDelta).toBeGreaterThanOrEqual(BigInt(valueWei)); + }, 60000); // Increased Jest timeout to 60s for potential network delays +}); \ No newline at end of file diff --git a/networks/ethereum/package.json b/networks/ethereum/package.json index f5aa32f0..74351130 100644 --- a/networks/ethereum/package.json +++ b/networks/ethereum/package.json @@ -25,6 +25,7 @@ "lint": "eslint . --fix", "test:devnet": "npx jest --preset ts-jest devnet/__tests__/send.icjs.test.ts", "test:ethers": "npx jest --preset ts-jest devnet/__tests__/ethers.test.ts", + "test:noethers": "npx jest --preset ts-jest devnet/__tests__/noethers.test.ts", "run-ganache": "bash devnet/run-ganache.sh" }, "dependencies": { diff --git a/networks/ethereum/src/EthereumTransfer.ts b/networks/ethereum/src/EthereumTransfer.ts new file mode 100644 index 00000000..cd83f941 --- /dev/null +++ b/networks/ethereum/src/EthereumTransfer.ts @@ -0,0 +1,326 @@ +// EthereumTransfer.ts +import axios from 'axios'; +import { keccak256 } from 'ethereum-cryptography/keccak'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { bytesToHex, hexToBytes, equalsBytes } from 'ethereum-cryptography/utils'; +import * as rlp from 'rlp'; // Updated import + +interface JsonRpcRequest { + jsonrpc: '2.0'; + method: string; + params: any[]; + id: number; +} + +export class EthereumTransferNoLibV12 { + private rpcUrl: string; + private privateKey: Uint8Array; + private publicKeyUncompressed: Uint8Array; // 65 bytes => 0x04 + 64 bytes (x, y) + + constructor(privateKey: string, rpcUrl: string) { + // Strip "0x" and convert to bytes + this.privateKey = hexToBytes(privateKey.replace(/^0x/, '')); + this.rpcUrl = rpcUrl; + + // Derive uncompressed pubkey (65 bytes, starts with 0x04) + this.publicKeyUncompressed = secp256k1.getPublicKey(this.privateKey, false); + } + + /** + * Helper to pad hex strings to even length. + * Accepts string, bigint, or number. + * Converts number to bigint internally. + * @param value Hex string, bigint, or number + * @returns Padded hex string with '0x' prefix + */ + private toHexPadded(value: string | bigint | number): string { + let hex: string; + if (typeof value === 'number') { + // Convert number to bigint to handle large values and maintain precision + hex = BigInt(value).toString(16); + } else if (typeof value === 'bigint') { + hex = value.toString(16); + } else { // string + hex = value.startsWith('0x') ? value.slice(2) : value; + } + if (hex.length % 2 !== 0) { + hex = '0' + hex; + } + return '0x' + hex; + } + + /** + * Derive Ethereum address from privateKey. + */ + private getAddressFromPrivateKey(): string { + const pubNoPrefix = this.publicKeyUncompressed.slice(1); // remove 0x04 + const hash = keccak256(pubNoPrefix); + const addressBytes = hash.slice(-20); + return '0x' + bytesToHex(addressBytes); + } + + /** + * Query current nonce from the node. + */ + private async getNonce(address: string): Promise { + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: [address, 'latest'], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, payload); + return parseInt(resp.data.result, 16); + } + + /** + * Query chainId (e.g., 1 = mainnet). + */ + private async getChainId(): Promise { + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_chainId', + params: [], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, payload); + return parseInt(resp.data.result, 16); + } + + /** + * Query gasPrice (wei) from the node. + */ + async getGasPrice(): Promise { + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_gasPrice', + params: [], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, payload); + return BigInt(resp.data.result); + } + + /** + * Sign a given msgHash. + * Because @noble/curves@1.2.0 does not allow specifying a recoveryBit, + * we use `signature.recoverPublicKey(msgHash)` once and compare with our known pubkey. + * If they match => recBit=0, else => recBit=1. + */ + private signWithRecovery(msgHash: Uint8Array): { r: Uint8Array; s: Uint8Array; recovery: number } { + // 1) Sign => Signature object + const signature = secp256k1.sign(msgHash, this.privateKey); + + // 2) 64-byte raw => r(32)+s(32) + const compactSig = signature.toCompactRawBytes(); + const r = compactSig.slice(0, 32); + const s = compactSig.slice(32, 64); + + // 3) Recover public key from signature + const recoveredPoint = signature.recoverPublicKey(msgHash); + const recoveredPubBytes = recoveredPoint.toRawBytes(false); // uncompressed, 65 bytes + + // 4) Compare + if (equalsBytes(recoveredPubBytes, this.publicKeyUncompressed)) { + return { r, s, recovery: 0 }; + } else { + return { r, s, recovery: 1 }; + } + } + + /** + * Send a legacy (pre-EIP1559) transaction with gasPrice. + */ + public async sendLegacyTransaction( + to: string, + valueWei: bigint, + gasPrice: bigint, + gasLimit: bigint + ): Promise { + const fromAddr = this.getAddressFromPrivateKey(); + console.log('from address in sendLegacyTransaction:', fromAddr); + const nonce = await this.getNonce(fromAddr); + const chainId = await this.getChainId(); + + // Convert inputs to padded hex strings + const nonceHex = this.toHexPadded(nonce); + const gasPriceHex = this.toHexPadded(gasPrice); + const gasLimitHex = this.toHexPadded(gasLimit); + const valueHex = this.toHexPadded(valueWei); + const dataHex = '0x'; + + // RLP for signing (chainId in item #7, then 0,0 placeholders) + const txForSign = [ + hexToBytes(nonceHex), + hexToBytes(gasPriceHex), + hexToBytes(gasLimitHex), + hexToBytes(to), + hexToBytes(valueHex), + hexToBytes(dataHex), + hexToBytes(this.toHexPadded(chainId)), + new Uint8Array([]), + new Uint8Array([]) + ]; + + const unsignedTx = rlp.encode(txForSign); + const msgHash = keccak256(unsignedTx); + + const { r, s, recovery } = this.signWithRecovery(msgHash); + + // EIP-155 => v = chainId*2 + 35 + recovery + const v = BigInt(chainId) * 2n + 35n + BigInt(recovery); + const vHex = this.toHexPadded(v); + + const txSigned = [ + hexToBytes(nonceHex), + hexToBytes(gasPriceHex), + hexToBytes(gasLimitHex), + hexToBytes(to), + hexToBytes(valueHex), + hexToBytes(dataHex), + hexToBytes(vHex), + r, + s + ]; + console.log('txSigned:', txSigned); + + const serializedTx = rlp.encode(txSigned); + const rawTxHex = '0x' + bytesToHex(serializedTx); + + const sendPayload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_sendRawTransaction', + params: [rawTxHex], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, sendPayload); + if (resp.data.result) { + return resp.data.result; + } else if (resp.data.error) { + throw new Error(JSON.stringify(resp.data.error)); + } else { + throw new Error('Unknown error from eth_sendRawTransaction'); + } + } + + /** + * Helper to automatically fetch the gasPrice, then send a legacy transaction. + */ + public async sendLegacyTransactionAutoGas( + to: string, + valueWei: bigint, + gasLimit: bigint + ): Promise { + const autoGasPrice = await this.getGasPrice(); + return this.sendLegacyTransaction(to, valueWei, autoGasPrice, gasLimit); + } + + /** + * Send an EIP-1559 typed transaction (0x02). + * For simplicity, we do not handle access lists. We pass an empty array. + * @param to Recipient address + * @param valueWei Amount in wei + * @param maxPriorityFeePerGas The tip (in wei) + * @param maxFeePerGas The total fee cap (in wei) + * @param gasLimit Gas limit + * @param data Optional data for contract calls (default '0x') + */ + public async sendEIP1559Transaction( + to: string, + valueWei: string, + maxPriorityFeePerGas: string, + maxFeePerGas: string, + gasLimit: string, + data: string = '0x' + ): Promise { + const fromAddr = this.getAddressFromPrivateKey(); + const nonce = await this.getNonce(fromAddr); + const chainId = await this.getChainId(); + + // Convert fields to padded hex strings + const chainIdHex = this.toHexPadded(chainId); + const nonceHex = this.toHexPadded(nonce); + const maxPriorityHex = this.toHexPadded(maxPriorityFeePerGas); + const maxFeeHex = this.toHexPadded(maxFeePerGas); + const gasLimitHex = this.toHexPadded(gasLimit); + const valueHex = this.toHexPadded(valueWei); + + // EIP-1559 typed transaction: + // 0x02 + RLP([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s]) + // + // For signing, we omit [v, r, s]. + // The "accessList" can be empty for simple transfers: []. + + const accessList: any[] = []; // or define a real access list if needed + const txForSign = [ + hexToBytes(chainIdHex), + hexToBytes(nonceHex), + hexToBytes(maxPriorityHex), + hexToBytes(maxFeeHex), + hexToBytes(gasLimitHex), + hexToBytes(to), + hexToBytes(valueHex), + hexToBytes(data), + accessList + ]; + + // According to spec, the signed message is keccak256( 0x02 || RLP( txForSign ) ) + const encodedTxForSign = rlp.encode(txForSign); + + // Construct domain separator (type byte 0x02) + const domainSeparator = new Uint8Array([0x02]); + + // Concatenate domain + RLP-encoded data + const toBeHashed = new Uint8Array(domainSeparator.length + encodedTxForSign.length); + toBeHashed.set(domainSeparator, 0); + toBeHashed.set(encodedTxForSign, domainSeparator.length); + + const msgHash = keccak256(toBeHashed); + + // Sign + const { r, s, recovery } = this.signWithRecovery(msgHash); + + // For typed transactions, v is commonly 27 + recovery (NOT chainId-based as in EIP-155) + const v = 27 + recovery; + const vHex = this.toHexPadded(v); + + // Now the final transaction for broadcast: + // 0x02 + RLP( [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s] ) + const txSigned = [ + hexToBytes(chainIdHex), + hexToBytes(nonceHex), + hexToBytes(maxPriorityHex), + hexToBytes(maxFeeHex), + hexToBytes(gasLimitHex), + hexToBytes(to), + hexToBytes(valueHex), + hexToBytes(data), + accessList, + hexToBytes(vHex), + r, + s + ]; + + const encodedTxSigned = rlp.encode(txSigned); + + // The final raw transaction hex is prefixed by '0x02' + const typedRawTx = '0x02' + bytesToHex(encodedTxSigned); + + // Broadcast + const sendPayload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_sendRawTransaction', + params: [typedRawTx], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, sendPayload); + + if (resp.data.result) { + return resp.data.result; // Transaction hash + } else if (resp.data.error) { + throw new Error(JSON.stringify(resp.data.error)); + } else { + throw new Error('Unknown error from eth_sendRawTransaction'); + } + } +} \ No newline at end of file From 9feb4443c871b2f02046af01c50091ff5a955d7e Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Fri, 10 Jan 2025 14:58:30 +0800 Subject: [PATCH 03/11] mint more tokens when start --- networks/ethereum/devnet/run-ganache.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/networks/ethereum/devnet/run-ganache.sh b/networks/ethereum/devnet/run-ganache.sh index 6e38b4a7..564b345c 100644 --- a/networks/ethereum/devnet/run-ganache.sh +++ b/networks/ethereum/devnet/run-ganache.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash npx ganache \ - --account="0x0000000000000000000000000000000000000000000000000000000000000001,1000000000000000000" \ - --account="0x0000000000000000000000000000000000000000000000000000000000000002,1000000000000000000" \ No newline at end of file + --account="0x0000000000000000000000000000000000000000000000000000000000000001,100000000000000000000" \ + --account="0x0000000000000000000000000000000000000000000000000000000000000002,100000000000000000000" \ No newline at end of file From 10ec6e4e289a479ca0fd1515afb8f6eef0bb9c75 Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Fri, 10 Jan 2025 14:58:51 +0800 Subject: [PATCH 04/11] calc gas price and limit automatically --- .../devnet/__tests__/noethers.test.ts | 77 ++------ ...eumTransfer.ts => SignerFromPrivateKey.ts} | 176 +++++++++++++++--- 2 files changed, 171 insertions(+), 82 deletions(-) rename networks/ethereum/src/{EthereumTransfer.ts => SignerFromPrivateKey.ts} (67%) diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts index 11e057a7..ebe38d0c 100644 --- a/networks/ethereum/devnet/__tests__/noethers.test.ts +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -1,10 +1,6 @@ -// EthereumTransferNoLibV12.legacy.test.ts -import { EthereumTransferNoLibV12 } from '../../src/EthereumTransfer'; +import { SignerFromPrivateKey } from '../../src/SignerFromPrivateKey'; // Adjust the import path as needed import axios from 'axios'; -import { hexToBytes, bytesToHex } from 'ethereum-cryptography/utils'; -import { secp256k1 } from '@noble/curves/secp256k1'; -import { keccak256 } from 'ethereum-cryptography/keccak'; // RPC endpoint for your local/test environment. // E.g., Hardhat node: http://127.0.0.1:8545 @@ -15,81 +11,46 @@ const RPC_URL = 'http://127.0.0.1:8545'; const privSender = '0x' + '0'.repeat(63) + '1'; const privReceiver = '0x' + '0'.repeat(63) + '2'; -/** - * Helper to derive an address from a private key (uncompressed). - */ -function deriveAddressFromPrivateKey(pk: string): string { - const pkBytes = hexToBytes(pk.replace(/^0x/, '')); - const pubUncompressed = secp256k1.getPublicKey(pkBytes, false); // 65 bytes - const pubNoPrefix = pubUncompressed.slice(1); // remove 0x04 - const hash = keccak256(pubNoPrefix); - const addrBytes = hash.slice(-20); - return '0x' + bytesToHex(addrBytes); -} - -/** - * Helper to query ETH balance via JSON-RPC (eth_getBalance). - * Returns a BigInt (balance in wei). - */ -async function getBalance(address: string): Promise { - const payload = { - jsonrpc: '2.0', - method: 'eth_getBalance', - params: [address, 'latest'], - id: 1, - }; - const resp = await axios.post(RPC_URL, payload); - return BigInt(resp.data.result); -} - -describe('EthereumTransferNoLibV12 (Legacy Transaction) Tests', () => { - const senderAddress = deriveAddressFromPrivateKey(privSender); - console.log('Derived senderAddress =', senderAddress); - const receiverAddress = deriveAddressFromPrivateKey(privReceiver); +const signerSender = new SignerFromPrivateKey(privSender, RPC_URL); +const signerReceiver = new SignerFromPrivateKey(privReceiver, RPC_URL); + +describe('sending Tests', () => { + const receiverAddress = signerReceiver.getAddress() // Instance to send from privSender - let transfer: EthereumTransferNoLibV12; + let transfer: SignerFromPrivateKey; beforeAll(async () => { - transfer = new EthereumTransferNoLibV12(privSender, RPC_URL); + transfer = new SignerFromPrivateKey(privSender, RPC_URL); const chainId = await transfer['getChainId'](); console.log('chainId from node:', chainId); }); - it('should send legacy transaction from sender to receiver, and check balances', async () => { + it('should send ETH from sender to receiver, and check balances', async () => { // 1) Check initial balances - const beforeSenderBalance = await getBalance(senderAddress); - const beforeReceiverBalance = await getBalance(receiverAddress); + const beforeSenderBalance = await signerSender.getBalance(); + const beforeReceiverBalance = await signerReceiver.getBalance(); console.log('Sender balance before:', beforeSenderBalance.toString()); console.log('Receiver balance before:', beforeReceiverBalance.toString()); - // 2) Prepare legacy transaction fields + // 2) Prepare transaction fields // e.g., sending const valueWei = 10000000000000000n; // 0.01 ETH - const gasLimit = 21000n; // minimal for simple transfer - - // 3) Fetch gas price from the node - const gasPrice = await transfer.getGasPrice(); - console.log('Current gas price:', gasPrice.toString()); - - console.log('gasPrice * gasLimit', gasPrice*BigInt(gasLimit)) // **New Code Addition: Print Sender's Balance Before Sending** // This is to verify the sender's balance right before the transaction - const currentSenderBalance = await getBalance(senderAddress); + const currentSenderBalance = await signerSender.getBalance(); console.log('Sender balance right before sending:', currentSenderBalance.toString()); - // 4) Send legacy transaction - const txHash = await transfer.sendLegacyTransaction( + // 4) Send transaction + const txHash = await transfer.sendLegacyTransactionAutoGasLimit( receiverAddress, - valueWei, - gasPrice, // Convert BigInt to string - gasLimit + valueWei ); expect(txHash).toMatch(/^0x[0-9a-fA-F]+$/); - console.log('Legacy txHash:', txHash); + console.log('sending txHash:', txHash); // 5) (Optional) Wait for transaction to be mined // For local nodes, this might not be necessary as transactions are mined instantly @@ -116,8 +77,8 @@ describe('EthereumTransferNoLibV12 (Legacy Transaction) Tests', () => { expect(receipt.status).toBe('0x1'); // '0x1' indicates success // 6) Check final balances - const afterSenderBalance = await getBalance(senderAddress); - const afterReceiverBalance = await getBalance(receiverAddress); + const afterSenderBalance = await signerSender.getBalance(); + const afterReceiverBalance = await signerReceiver.getBalance(); console.log('Sender balance after:', afterSenderBalance.toString()); console.log('Receiver balance after:', afterReceiverBalance.toString()); diff --git a/networks/ethereum/src/EthereumTransfer.ts b/networks/ethereum/src/SignerFromPrivateKey.ts similarity index 67% rename from networks/ethereum/src/EthereumTransfer.ts rename to networks/ethereum/src/SignerFromPrivateKey.ts index cd83f941..a67f4998 100644 --- a/networks/ethereum/src/EthereumTransfer.ts +++ b/networks/ethereum/src/SignerFromPrivateKey.ts @@ -12,7 +12,7 @@ interface JsonRpcRequest { id: number; } -export class EthereumTransferNoLibV12 { +export class SignerFromPrivateKey { private rpcUrl: string; private privateKey: Uint8Array; private publicKeyUncompressed: Uint8Array; // 65 bytes => 0x04 + 64 bytes (x, y) @@ -52,7 +52,7 @@ export class EthereumTransferNoLibV12 { /** * Derive Ethereum address from privateKey. */ - private getAddressFromPrivateKey(): string { + getAddress(): string { const pubNoPrefix = this.publicKeyUncompressed.slice(1); // remove 0x04 const hash = keccak256(pubNoPrefix); const addressBytes = hash.slice(-20); @@ -101,6 +101,30 @@ export class EthereumTransferNoLibV12 { return BigInt(resp.data.result); } + public async getBalance(): Promise { + const address = this.getAddress(); + + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_getBalance', + params: [address, 'latest'], + id: 1 + }; + + try { + const resp = await axios.post(this.rpcUrl, payload); + if (resp.data.result) { + return BigInt(resp.data.result); + } else if (resp.data.error) { + throw new Error(JSON.stringify(resp.data.error)); + } else { + throw new Error('Unknown error from eth_getBalance'); + } + } catch (error) { + throw new Error(`Failed to fetch balance: ${error}`); + } + } + /** * Sign a given msgHash. * Because @noble/curves@1.2.0 does not allow specifying a recoveryBit, @@ -108,24 +132,18 @@ export class EthereumTransferNoLibV12 { * If they match => recBit=0, else => recBit=1. */ private signWithRecovery(msgHash: Uint8Array): { r: Uint8Array; s: Uint8Array; recovery: number } { - // 1) Sign => Signature object + // Sign the message hash const signature = secp256k1.sign(msgHash, this.privateKey); - - // 2) 64-byte raw => r(32)+s(32) + + // Extract r and s values const compactSig = signature.toCompactRawBytes(); const r = compactSig.slice(0, 32); const s = compactSig.slice(32, 64); - - // 3) Recover public key from signature - const recoveredPoint = signature.recoverPublicKey(msgHash); - const recoveredPubBytes = recoveredPoint.toRawBytes(false); // uncompressed, 65 bytes - - // 4) Compare - if (equalsBytes(recoveredPubBytes, this.publicKeyUncompressed)) { - return { r, s, recovery: 0 }; - } else { - return { r, s, recovery: 1 }; - } + + // Directly use the recovery parameter from the signature + const recovery = signature.recovery; + + return { r, s, recovery }; } /** @@ -134,12 +152,14 @@ export class EthereumTransferNoLibV12 { public async sendLegacyTransaction( to: string, valueWei: bigint, + dataHex = '0x', gasPrice: bigint, gasLimit: bigint ): Promise { - const fromAddr = this.getAddressFromPrivateKey(); + const fromAddr = this.getAddress(); console.log('from address in sendLegacyTransaction:', fromAddr); const nonce = await this.getNonce(fromAddr); + console.log('Nonce:', nonce); const chainId = await this.getChainId(); // Convert inputs to padded hex strings @@ -147,7 +167,6 @@ export class EthereumTransferNoLibV12 { const gasPriceHex = this.toHexPadded(gasPrice); const gasLimitHex = this.toHexPadded(gasLimit); const valueHex = this.toHexPadded(valueWei); - const dataHex = '0x'; // RLP for signing (chainId in item #7, then 0,0 placeholders) const txForSign = [ @@ -186,6 +205,7 @@ export class EthereumTransferNoLibV12 { const serializedTx = rlp.encode(txSigned); const rawTxHex = '0x' + bytesToHex(serializedTx); + console.log('Serialized Transaction:', rawTxHex); const sendPayload: JsonRpcRequest = { jsonrpc: '2.0', @@ -209,10 +229,12 @@ export class EthereumTransferNoLibV12 { public async sendLegacyTransactionAutoGas( to: string, valueWei: bigint, + dataHex = '0x', gasLimit: bigint ): Promise { const autoGasPrice = await this.getGasPrice(); - return this.sendLegacyTransaction(to, valueWei, autoGasPrice, gasLimit); + console.log('Auto gas price:', autoGasPrice.toString()); + return this.sendLegacyTransaction(to, valueWei, dataHex, autoGasPrice, gasLimit); } /** @@ -227,13 +249,13 @@ export class EthereumTransferNoLibV12 { */ public async sendEIP1559Transaction( to: string, - valueWei: string, - maxPriorityFeePerGas: string, - maxFeePerGas: string, - gasLimit: string, + valueWei: bigint, + maxPriorityFeePerGas: bigint, + maxFeePerGas: bigint, + gasLimit: bigint, data: string = '0x' ): Promise { - const fromAddr = this.getAddressFromPrivateKey(); + const fromAddr = this.getAddress(); const nonce = await this.getNonce(fromAddr); const chainId = await this.getChainId(); @@ -323,4 +345,110 @@ export class EthereumTransferNoLibV12 { throw new Error('Unknown error from eth_sendRawTransaction'); } } + + /** + * Helper to automatically fetch the gasPrice and estimate gasLimit, + * then send a legacy transaction with calculated gasLimit (1.5x estimated). + */ +public async sendLegacyTransactionAutoGasLimit( + to: string, + valueWei: bigint, + dataHex = '0x' +): Promise { + const gasPrice = await this.getGasPrice(); + + // Estimate gas limit from the node + const estimatedGasLimit = await this.estimateGas(to, valueWei, dataHex); + const gasLimit = BigInt(Math.ceil(Number(estimatedGasLimit) * 1.5)); // 1.5x estimated + console.log('Gas Limit:', gasLimit.toString()) + + console.log('Value:', valueWei.toString()); + return this.sendLegacyTransaction(to, valueWei, dataHex, gasPrice, gasLimit); +} + +/** + * Helper to automatically fetch maxPriorityFeePerGas, maxFeePerGas, + * estimate gasLimit, and then send an EIP-1559 transaction. + * Accepts only `to`, `valueWei`, and optional `data`. + */ +public async sendEIP1559TransactionAutoGasLimit( + to: string, + valueWei: bigint, + data: string = '0x' +): Promise { + const maxPriorityFeePerGas = await this.getMaxPriorityFeePerGas(); + const maxFeePerGas = await this.getMaxFeePerGas(maxPriorityFeePerGas); + + // Estimate gas limit from the node + const estimatedGasLimit = await this.estimateGas(to, valueWei, data); + const gasLimit = BigInt(Math.ceil(Number(estimatedGasLimit) * 1.5)); // 1.5x estimated + + return this.sendEIP1559Transaction(to, valueWei, maxPriorityFeePerGas, maxFeePerGas, gasLimit, data); +} + +/** + * Estimate gas for a transaction. + * @param to Recipient address + * @param valueWei Amount in wei + * @param data Optional data (default is '0x') + * @returns Estimated gas as bigint + */ +private async estimateGas(to: string, valueWei: bigint, data: string): Promise { + const fromAddr = this.getAddress(); + + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_estimateGas', + params: [ + { + from: fromAddr, + to: to, + value: this.toHexPadded(valueWei), + data: data + } + ], + id: 1 + }; + + const resp = await axios.post(this.rpcUrl, payload); + + if (resp.data.result) { + return BigInt(resp.data.result); + } else if (resp.data.error) { + throw new Error(JSON.stringify(resp.data.error)); + } else { + throw new Error('Unknown error from eth_estimateGas'); + } +} + +/** + * Get maxPriorityFeePerGas from the node. + */ +private async getMaxPriorityFeePerGas(): Promise { + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_maxPriorityFeePerGas', + params: [], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, payload); + return BigInt(resp.data.result); +} + +/** + * Calculate maxFeePerGas as maxPriorityFeePerGas + baseFee. + * This uses eth_feeHistory to determine baseFee. + */ +private async getMaxFeePerGas(maxPriorityFeePerGas: bigint): Promise { + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_feeHistory', + params: [1, 'latest', []], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, payload); + + const baseFeePerGas = BigInt(resp.data.result.baseFeePerGas); + return baseFeePerGas + maxPriorityFeePerGas; +} } \ No newline at end of file From aa3c398aa992dcc036dcbaeb930840853179471e Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Fri, 10 Jan 2025 17:58:14 +0800 Subject: [PATCH 05/11] depoly contract --- .vscode/settings.json | 2 +- .../devnet/__tests__/noethers.test.ts | 21 +- networks/ethereum/src/SignerFromPrivateKey.ts | 208 +++++++++--------- networks/ethereum/src/utils/common.ts | 11 + 4 files changed, 138 insertions(+), 104 deletions(-) create mode 100644 networks/ethereum/src/utils/common.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index c508eea8..1abe9db5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,6 +16,6 @@ "codegen" ], "[typescript]": { - "editor.defaultFormatter": "rvest.vs-code-prettier-eslint" + "editor.defaultFormatter": "vscode.typescript-language-features" } } diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts index ebe38d0c..20d4559f 100644 --- a/networks/ethereum/devnet/__tests__/noethers.test.ts +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -1,6 +1,8 @@ -import { SignerFromPrivateKey } from '../../src/SignerFromPrivateKey'; +import { SignerFromPrivateKey } from '../../src/SignerFromPrivateKey'; // Adjust the import path as needed import axios from 'axios'; +import { computeContractAddress } from '../../src/utils/common'; +import { bytecode } from '../../contracts/usdt/contract.json' // RPC endpoint for your local/test environment. // E.g., Hardhat node: http://127.0.0.1:8545 @@ -15,15 +17,32 @@ const signerSender = new SignerFromPrivateKey(privSender, RPC_URL); const signerReceiver = new SignerFromPrivateKey(privReceiver, RPC_URL); describe('sending Tests', () => { + const senderAddress = signerSender.getAddress() const receiverAddress = signerReceiver.getAddress() // Instance to send from privSender let transfer: SignerFromPrivateKey; + let usdtAddress: string + beforeAll(async () => { transfer = new SignerFromPrivateKey(privSender, RPC_URL); const chainId = await transfer['getChainId'](); console.log('chainId from node:', chainId); + + const nonce = await transfer.getNonce(); + try { + await transfer.sendLegacyTransactionAutoGasLimit( + '', // no receiver while deploying smart contract + 0n, + bytecode + ); + usdtAddress = computeContractAddress(senderAddress, nonce); + console.log('Computed usdtAddress:', usdtAddress); + } catch (e) { + console.error('Error deploying contract:', e); + } + }); it('should send ETH from sender to receiver, and check balances', async () => { diff --git a/networks/ethereum/src/SignerFromPrivateKey.ts b/networks/ethereum/src/SignerFromPrivateKey.ts index a67f4998..c2f43b37 100644 --- a/networks/ethereum/src/SignerFromPrivateKey.ts +++ b/networks/ethereum/src/SignerFromPrivateKey.ts @@ -62,11 +62,11 @@ export class SignerFromPrivateKey { /** * Query current nonce from the node. */ - private async getNonce(address: string): Promise { + public async getNonce(): Promise { const payload: JsonRpcRequest = { jsonrpc: '2.0', method: 'eth_getTransactionCount', - params: [address, 'latest'], + params: [this.getAddress(), 'latest'], id: 1 }; const resp = await axios.post(this.rpcUrl, payload); @@ -103,7 +103,7 @@ export class SignerFromPrivateKey { public async getBalance(): Promise { const address = this.getAddress(); - + const payload: JsonRpcRequest = { jsonrpc: '2.0', method: 'eth_getBalance', @@ -134,15 +134,15 @@ export class SignerFromPrivateKey { private signWithRecovery(msgHash: Uint8Array): { r: Uint8Array; s: Uint8Array; recovery: number } { // Sign the message hash const signature = secp256k1.sign(msgHash, this.privateKey); - + // Extract r and s values const compactSig = signature.toCompactRawBytes(); const r = compactSig.slice(0, 32); const s = compactSig.slice(32, 64); - + // Directly use the recovery parameter from the signature const recovery = signature.recovery; - + return { r, s, recovery }; } @@ -158,7 +158,7 @@ export class SignerFromPrivateKey { ): Promise { const fromAddr = this.getAddress(); console.log('from address in sendLegacyTransaction:', fromAddr); - const nonce = await this.getNonce(fromAddr); + const nonce = await this.getNonce(); console.log('Nonce:', nonce); const chainId = await this.getChainId(); @@ -205,7 +205,7 @@ export class SignerFromPrivateKey { const serializedTx = rlp.encode(txSigned); const rawTxHex = '0x' + bytesToHex(serializedTx); - console.log('Serialized Transaction:', rawTxHex); + // console.log('Serialized Transaction:', rawTxHex); const sendPayload: JsonRpcRequest = { jsonrpc: '2.0', @@ -256,7 +256,7 @@ export class SignerFromPrivateKey { data: string = '0x' ): Promise { const fromAddr = this.getAddress(); - const nonce = await this.getNonce(fromAddr); + const nonce = await this.getNonce(); const chainId = await this.getChainId(); // Convert fields to padded hex strings @@ -350,105 +350,109 @@ export class SignerFromPrivateKey { * Helper to automatically fetch the gasPrice and estimate gasLimit, * then send a legacy transaction with calculated gasLimit (1.5x estimated). */ -public async sendLegacyTransactionAutoGasLimit( - to: string, - valueWei: bigint, - dataHex = '0x' -): Promise { - const gasPrice = await this.getGasPrice(); - - // Estimate gas limit from the node - const estimatedGasLimit = await this.estimateGas(to, valueWei, dataHex); - const gasLimit = BigInt(Math.ceil(Number(estimatedGasLimit) * 1.5)); // 1.5x estimated - console.log('Gas Limit:', gasLimit.toString()) - - console.log('Value:', valueWei.toString()); - return this.sendLegacyTransaction(to, valueWei, dataHex, gasPrice, gasLimit); -} + public async sendLegacyTransactionAutoGasLimit( + to: string, + valueWei: bigint, + dataHex = '0x' + ): Promise { + const gasPrice = await this.getGasPrice(); -/** - * Helper to automatically fetch maxPriorityFeePerGas, maxFeePerGas, - * estimate gasLimit, and then send an EIP-1559 transaction. - * Accepts only `to`, `valueWei`, and optional `data`. - */ -public async sendEIP1559TransactionAutoGasLimit( - to: string, - valueWei: bigint, - data: string = '0x' -): Promise { - const maxPriorityFeePerGas = await this.getMaxPriorityFeePerGas(); - const maxFeePerGas = await this.getMaxFeePerGas(maxPriorityFeePerGas); - - // Estimate gas limit from the node - const estimatedGasLimit = await this.estimateGas(to, valueWei, data); - const gasLimit = BigInt(Math.ceil(Number(estimatedGasLimit) * 1.5)); // 1.5x estimated - - return this.sendEIP1559Transaction(to, valueWei, maxPriorityFeePerGas, maxFeePerGas, gasLimit, data); -} + // Estimate gas limit from the node + const estimatedGasLimit = await this.estimateGas(to, valueWei, dataHex); + const gasLimit = BigInt(Math.ceil(Number(estimatedGasLimit) * 1.5)); // 1.5x estimated + console.log('Gas Limit:', gasLimit.toString()) -/** - * Estimate gas for a transaction. - * @param to Recipient address - * @param valueWei Amount in wei - * @param data Optional data (default is '0x') - * @returns Estimated gas as bigint - */ -private async estimateGas(to: string, valueWei: bigint, data: string): Promise { - const fromAddr = this.getAddress(); - - const payload: JsonRpcRequest = { - jsonrpc: '2.0', - method: 'eth_estimateGas', - params: [ - { - from: fromAddr, - to: to, - value: this.toHexPadded(valueWei), - data: data - } - ], - id: 1 - }; + console.log('Value:', valueWei.toString()); + return this.sendLegacyTransaction(to, valueWei, dataHex, gasPrice, gasLimit); + } + + /** + * Helper to automatically fetch maxPriorityFeePerGas, maxFeePerGas, + * estimate gasLimit, and then send an EIP-1559 transaction. + * Accepts only `to`, `valueWei`, and optional `data`. + */ + public async sendEIP1559TransactionAutoGasLimit( + to: string, + valueWei: bigint, + data: string = '0x' + ): Promise { + const maxPriorityFeePerGas = await this.getMaxPriorityFeePerGas(); + const maxFeePerGas = await this.getMaxFeePerGas(maxPriorityFeePerGas); + + // Estimate gas limit from the node + const estimatedGasLimit = await this.estimateGas(to, valueWei, data); + const gasLimit = BigInt(Math.ceil(Number(estimatedGasLimit) * 1.5)); // 1.5x estimated + + return this.sendEIP1559Transaction(to, valueWei, maxPriorityFeePerGas, maxFeePerGas, gasLimit, data); + } + + /** + * Estimate gas for a transaction. + * @param to Recipient address + * @param valueWei Amount in wei + * @param data Optional data (default is '0x') + * @returns Estimated gas as bigint + */ + private async estimateGas(to: string, valueWei: bigint, data: string): Promise { + const fromAddr = this.getAddress(); + + const txParams: any = { + from: fromAddr, + value: this.toHexPadded(valueWei), + data: data + }; + + // 如果 `to` 不是空字符串,则添加 `to` 字段 + if (to && to !== '') { + txParams.to = to; + } + + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_estimateGas', + params: [txParams], + id: 1 + }; - const resp = await axios.post(this.rpcUrl, payload); + const resp = await axios.post(this.rpcUrl, payload); - if (resp.data.result) { + if (resp.data.result) { + return BigInt(resp.data.result); + } else if (resp.data.error) { + throw new Error(JSON.stringify(resp.data.error)); + } else { + throw new Error('Unknown error from eth_estimateGas'); + } + } + + /** + * Get maxPriorityFeePerGas from the node. + */ + private async getMaxPriorityFeePerGas(): Promise { + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_maxPriorityFeePerGas', + params: [], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, payload); return BigInt(resp.data.result); - } else if (resp.data.error) { - throw new Error(JSON.stringify(resp.data.error)); - } else { - throw new Error('Unknown error from eth_estimateGas'); } -} -/** - * Get maxPriorityFeePerGas from the node. - */ -private async getMaxPriorityFeePerGas(): Promise { - const payload: JsonRpcRequest = { - jsonrpc: '2.0', - method: 'eth_maxPriorityFeePerGas', - params: [], - id: 1 - }; - const resp = await axios.post(this.rpcUrl, payload); - return BigInt(resp.data.result); -} + /** + * Calculate maxFeePerGas as maxPriorityFeePerGas + baseFee. + * This uses eth_feeHistory to determine baseFee. + */ + private async getMaxFeePerGas(maxPriorityFeePerGas: bigint): Promise { + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_feeHistory', + params: [1, 'latest', []], + id: 1 + }; + const resp = await axios.post(this.rpcUrl, payload); -/** - * Calculate maxFeePerGas as maxPriorityFeePerGas + baseFee. - * This uses eth_feeHistory to determine baseFee. - */ -private async getMaxFeePerGas(maxPriorityFeePerGas: bigint): Promise { - const payload: JsonRpcRequest = { - jsonrpc: '2.0', - method: 'eth_feeHistory', - params: [1, 'latest', []], - id: 1 - }; - const resp = await axios.post(this.rpcUrl, payload); - - const baseFeePerGas = BigInt(resp.data.result.baseFeePerGas); - return baseFeePerGas + maxPriorityFeePerGas; -} + const baseFeePerGas = BigInt(resp.data.result.baseFeePerGas); + return baseFeePerGas + maxPriorityFeePerGas; + } } \ No newline at end of file diff --git a/networks/ethereum/src/utils/common.ts b/networks/ethereum/src/utils/common.ts new file mode 100644 index 00000000..b2b28d01 --- /dev/null +++ b/networks/ethereum/src/utils/common.ts @@ -0,0 +1,11 @@ +import { keccak256 } from 'ethereum-cryptography/keccak'; +import * as rlp from 'rlp'; +import { hexToBytes, bytesToHex } from 'ethereum-cryptography/utils'; + +export const computeContractAddress = (fromAddress: string, nonce: number): string => { + const fromBytes = hexToBytes(fromAddress.toLowerCase()); + const rlpEncoded = rlp.encode([fromBytes, nonce]); + const hash = keccak256(rlpEncoded); + const contractAddress = '0x' + bytesToHex(hash.slice(-20)); + return contractAddress; +}; \ No newline at end of file From beb108ed599d9f38c5a79b93e13c815b103b3919 Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Mon, 13 Jan 2025 01:19:02 +0800 Subject: [PATCH 06/11] erc20 get balance and send token success --- .../devnet/__tests__/noethers.test.ts | 93 ++++-- .../ethereum/src/utils/ContractEncoder.ts | 55 ++++ networks/ethereum/src/utils/abiEncoder.ts | 264 ++++++++++++++++++ 3 files changed, 390 insertions(+), 22 deletions(-) create mode 100644 networks/ethereum/src/utils/ContractEncoder.ts create mode 100644 networks/ethereum/src/utils/abiEncoder.ts diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts index 20d4559f..059d7729 100644 --- a/networks/ethereum/devnet/__tests__/noethers.test.ts +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -2,7 +2,8 @@ import { SignerFromPrivateKey } from '../../src/SignerFromPrivateKey'; // Adjust the import path as needed import axios from 'axios'; import { computeContractAddress } from '../../src/utils/common'; -import { bytecode } from '../../contracts/usdt/contract.json' +import { bytecode, abi } from '../../contracts/usdt/contract.json' +import { ContractEncoder, AbiFunctionItem } from '../../src/utils/ContractEncoder'; // RPC endpoint for your local/test environment. // E.g., Hardhat node: http://127.0.0.1:8545 @@ -25,6 +26,48 @@ describe('sending Tests', () => { let usdtAddress: string + const usdt = new ContractEncoder(abi as AbiFunctionItem[]); + + async function getUSDTBalance(addr: string): Promise { + const dataHex = usdt.balanceOf(addr); + const callPayload = { + jsonrpc: '2.0', + method: 'eth_call', + params: [ + { + to: usdtAddress, + data: dataHex, + }, + 'latest', + ], + id: 1, + }; + + const resp = await axios.post(RPC_URL, callPayload); + const hexBalance = resp.data.result; + return BigInt(hexBalance); + } + + // For local nodes, this might not be necessary as transactions are mined instantly + // For testnets, implement a polling mechanism or use WebSocket subscriptions + // Here, we'll poll for the receipt until it's available + async function getTransactionReceipt(txHash: string): Promise { + while (true) { + const receiptPayload = { + jsonrpc: '2.0', + method: 'eth_getTransactionReceipt', + params: [txHash], + id: 1, + }; + const receiptResp = await axios.post(RPC_URL, receiptPayload); + if (receiptResp.data.result) { + return receiptResp.data.result; + } + // Wait for a short interval before retrying + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + beforeAll(async () => { transfer = new SignerFromPrivateKey(privSender, RPC_URL); const chainId = await transfer['getChainId'](); @@ -71,27 +114,6 @@ describe('sending Tests', () => { console.log('sending txHash:', txHash); - // 5) (Optional) Wait for transaction to be mined - // For local nodes, this might not be necessary as transactions are mined instantly - // For testnets, implement a polling mechanism or use WebSocket subscriptions - // Here, we'll poll for the receipt until it's available - async function getTransactionReceipt(txHash: string): Promise { - while (true) { - const receiptPayload = { - jsonrpc: '2.0', - method: 'eth_getTransactionReceipt', - params: [txHash], - id: 1, - }; - const receiptResp = await axios.post(RPC_URL, receiptPayload); - if (receiptResp.data.result) { - return receiptResp.data.result; - } - // Wait for a short interval before retrying - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - const receipt = await getTransactionReceipt(txHash); expect(receipt.status).toBe('0x1'); // '0x1' indicates success @@ -116,4 +138,31 @@ describe('sending Tests', () => { // So, we just check that the sender's lost amount >= valueWei expect(senderDelta).toBeGreaterThanOrEqual(BigInt(valueWei)); }, 60000); // Increased Jest timeout to 60s for potential network delays + + it('should transfer USDT to receiver and verify balance increments by the transfer amount', async () => { + const beforeReceiverBalance = await getUSDTBalance(receiverAddress); + console.log('Before transfer, receiver USDT balance:', beforeReceiverBalance.toString()); + + const transferAmount = 1_000_000_000_000_000_000n; // 1 USDT + + const dataHex = usdt.transfer(receiverAddress, transferAmount); + + const txHash = await transfer.sendLegacyTransactionAutoGasLimit( + usdtAddress, + 0n, + dataHex + ); + expect(txHash).toMatch(/^0x[0-9a-fA-F]+$/); + + const receipt = await getTransactionReceipt(txHash); + expect(receipt.status).toBe('0x1'); + + const afterReceiverBalance = await getUSDTBalance(receiverAddress); + console.log('After transfer, receiver USDT balance:', afterReceiverBalance.toString()); + + const delta = afterReceiverBalance - beforeReceiverBalance; + console.log('Receiver USDT balance delta:', delta.toString()); + expect(delta).toBe(transferAmount); + }); + }); \ No newline at end of file diff --git a/networks/ethereum/src/utils/ContractEncoder.ts b/networks/ethereum/src/utils/ContractEncoder.ts new file mode 100644 index 00000000..19fb8843 --- /dev/null +++ b/networks/ethereum/src/utils/ContractEncoder.ts @@ -0,0 +1,55 @@ +import { encodeParameters, getFunctionSelector } from './abiEncoder'; + +export interface AbiFunctionItem { + type: 'function' | string; + name: string; + inputs: Array<{ name?: string; type: string }>; +} + +export class ContractEncoder { + [key: string]: any; + + private functionsMap: Map = new Map(); + + constructor(abi: AbiFunctionItem[]) { + for (const item of abi) { + if (item.type === 'function') { + const fnName = item.name; + if (!this.functionsMap.has(fnName)) { + this.functionsMap.set(fnName, []); + } + this.functionsMap.get(fnName)!.push(item); + } + } + + return new Proxy(this, { + get: (target, propertyKey, receiver) => { + if (typeof propertyKey === 'string' && target.functionsMap.has(propertyKey)) { + return (...args: any[]) => { + const candidates = target.functionsMap.get(propertyKey)!; + const matched = candidates.filter(c => c.inputs.length === args.length); + if (matched.length === 0) { + throw new Error(`No matching overload for function "${propertyKey}" with ${args.length} args`); + } else if (matched.length === 1) { + return target.encodeCall(matched[0], args); + } else { + throw new Error(`Multiple overloads for function "${propertyKey}" with ${args.length} args. You need more strict matching logic.`); + } + }; + } + return Reflect.get(target, propertyKey, receiver); + } + }); + } + + private encodeCall(fnAbi: AbiFunctionItem, args: any[]): string { + const types = fnAbi.inputs.map(i => i.type); + const signature = fnAbi.name + '(' + types.join(',') + ')'; + + const selector = getFunctionSelector(signature); + + const encodedArgs = encodeParameters(types, args); + + return '0x' + selector + encodedArgs; + } +} \ No newline at end of file diff --git a/networks/ethereum/src/utils/abiEncoder.ts b/networks/ethereum/src/utils/abiEncoder.ts new file mode 100644 index 00000000..f48f3356 --- /dev/null +++ b/networks/ethereum/src/utils/abiEncoder.ts @@ -0,0 +1,264 @@ +import { keccak256 } from 'js-sha3'; + +function leftPadZeros(value: string, length: number): string { + return value.padStart(length, '0'); +} + +function toHex(value: number | bigint | string): string { + if (typeof value === 'number' || typeof value === 'bigint') { + return value.toString(16); + } else if (typeof value === 'string') { + if (value.startsWith('0x')) { + return value.slice(2); + } else { + const num = BigInt(value); + return num.toString(16); + } + } else { + throw new Error(`Cannot convert value=${value} to hex`); + } +} + +function encodeUint256(value: number | bigint | string): string { + let hexValue = toHex(value); + hexValue = leftPadZeros(hexValue, 64); + return hexValue; +} + +function encodeBoolean(value: boolean): string { + return leftPadZeros(value ? '1' : '0', 64); +} + +function encodeAddress(addr: string): string { + const without0x = addr.replace(/^0x/i, ''); + return leftPadZeros(without0x.toLowerCase(), 64); +} + + +interface EncodedParameter { + headLength: number; + + encodeHead(offsetInBytes: number): string; + + encodeTail(): string; +} + +function encodeSingleParameter(type: string, value: any): EncodedParameter { + const arrayMatch = type.match(/^(.*)\[(.*?)\]$/); + if (arrayMatch) { + const baseType = arrayMatch[1]; + const lengthInType = arrayMatch[2]; + + if (lengthInType === '') { + return encodeDynamicArray(baseType, value); + } else { + const fixedLen = parseInt(lengthInType, 10); + return encodeFixedArray(baseType, fixedLen, value); + } + } + + if (type.startsWith('bytes')) { + const match = type.match(/^bytes([0-9]+)$/); + if (match) { + // bytesN (1 <= N <= 32) + const n = parseInt(match[1]); + return encodeFixedBytes(value, n); + } else { + return encodeDynamicBytes(value); + } + } + + if (type === 'string') { + return encodeDynamicString(value); + } + + if (type === 'bool') { + const headVal = encodeBoolean(Boolean(value)); + return { + headLength: 32, + encodeHead: () => headVal, + encodeTail: () => '', + }; + } + + if (type === 'address') { + const headVal = encodeAddress(value); + return { + headLength: 32, + encodeHead: () => headVal, + encodeTail: () => '', + }; + } + + if (/^(u?int)([0-9]*)$/.test(type)) { + const headVal = encodeUint256(value); + return { + headLength: 32, + encodeHead: () => headVal, + encodeTail: () => '', + }; + } + + throw new Error(`Unsupported or unrecognized type: ${type}`); +} + +function encodeDynamicBytes(raw: string | Uint8Array): EncodedParameter { + let byteArray: Uint8Array; + if (typeof raw === 'string') { + if (raw.startsWith('0x')) { + const hex = raw.slice(2); + const arr = hex.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) ?? []; + byteArray = new Uint8Array(arr); + } else { + byteArray = new TextEncoder().encode(raw); + } + } else { + byteArray = raw; + } + + const lengthHex = encodeUint256(byteArray.length); + const dataHex = Buffer.from(byteArray).toString('hex'); + const mod = dataHex.length % 64; + const padLength = mod === 0 ? 0 : 64 - mod; + + const tailHex = dataHex + '0'.repeat(padLength); + + return { + headLength: 32, + encodeHead: (offsetInBytes: number) => { + const offsetHex = leftPadZeros(offsetInBytes.toString(16), 64); + return offsetHex; + }, + encodeTail: () => { + // [length(32bytes)] + [data(N bytes + padding)] + return lengthHex + tailHex; + }, + }; +} + +function encodeDynamicString(text: string): EncodedParameter { + return encodeDynamicBytes(text); +} + +function encodeDynamicArray(baseType: string, arr: any[]): EncodedParameter { + const encodedItems = arr.map((item) => encodeSingleParameter(baseType, item)); + return { + headLength: 32, + encodeHead: (offsetInBytes: number) => { + const offsetHex = leftPadZeros(offsetInBytes.toString(16), 64); + return offsetHex; + }, + encodeTail: () => { + let tail = encodeUint256(arr.length); + let totalHeadLength = 32 * arr.length; + const encodedHeads: string[] = []; + const encodedTails: string[] = []; + + let currentOffset = 32 * arr.length; + + for (let i = 0; i < encodedItems.length; i++) { + const enc = encodedItems[i]; + // head + const headHex = enc.encodeHead(currentOffset); + encodedHeads.push(headHex); + + // tail + const tailHex = enc.encodeTail(); + encodedTails.push(tailHex); + + const tailBytes = tailHex.length / 2; // 2 hex = 1 byte + currentOffset += tailBytes; + } + + tail += encodedHeads.join(''); + tail += encodedTails.join(''); + return tail; + }, + }; +} + + +function encodeFixedArray(baseType: string, length: number, arr: any[]): EncodedParameter { + if (arr.length !== length) { + throw new Error(`Fixed array length mismatch: expect ${length}, got ${arr.length}`); + } + const encodedItems = arr.map((item) => encodeSingleParameter(baseType, item)); + + let totalHeadLength = 0; + for (const enc of encodedItems) { + totalHeadLength += enc.headLength; + } + + return { + headLength: totalHeadLength, + encodeHead: (offsetInBytes: number) => { + let heads = ''; + let currentOffset = 0; + for (const enc of encodedItems) { + const headHex = enc.encodeHead(offsetInBytes + totalHeadLength + currentOffset); + heads += headHex; + currentOffset += enc.encodeTail().length / 2; + } + return heads; + }, + encodeTail: () => { + let tails = ''; + for (const enc of encodedItems) { + tails += enc.encodeTail(); + } + return tails; + }, + }; +} + + +function encodeFixedBytes(value: string, length: number): EncodedParameter { + let hex = value.replace(/^0x/i, ''); + const maxLen = length * 2; // N bytes => 2*N hex + if (hex.length > maxLen) { + hex = hex.slice(0, maxLen); + } else if (hex.length < maxLen) { + hex = hex.padEnd(maxLen, '0'); + } + hex = hex.padEnd(64, '0'); + + return { + headLength: 32, + encodeHead: () => hex, + encodeTail: () => '', + }; +} + +export function encodeParameters(types: string[], values: any[]): string { + if (types.length !== values.length) { + throw new Error('Types count and values count do not match'); + } + + const encodedList = types.map((t, i) => encodeSingleParameter(t, values[i])); + + let totalHeadLength = 0; + for (const enc of encodedList) { + totalHeadLength += enc.headLength; + } + + let heads = ''; + let tails = ''; + let currentOffset = 0; + + for (const enc of encodedList) { + const headHex = enc.encodeHead(totalHeadLength + currentOffset); + heads += headHex; + + const tailHex = enc.encodeTail(); + tails += tailHex; + + currentOffset += tailHex.length / 2; + } + + return heads + tails; +} + +export function getFunctionSelector(signature: string): string { + const hashHex = keccak256(signature); + return hashHex.slice(0, 8); +} \ No newline at end of file From eba59440fcdd8ffb6e406de1dcd0ff6624e4c4ae Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Mon, 13 Jan 2025 01:30:33 +0800 Subject: [PATCH 07/11] relocate file path --- networks/ethereum/devnet/__tests__/noethers.test.ts | 2 +- networks/ethereum/src/{ => signers}/SignerFromPrivateKey.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename networks/ethereum/src/{ => signers}/SignerFromPrivateKey.ts (100%) diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts index 059d7729..ee7120dc 100644 --- a/networks/ethereum/devnet/__tests__/noethers.test.ts +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -1,4 +1,4 @@ -import { SignerFromPrivateKey } from '../../src/SignerFromPrivateKey'; +import { SignerFromPrivateKey } from '../../src/signers/SignerFromPrivateKey'; // Adjust the import path as needed import axios from 'axios'; import { computeContractAddress } from '../../src/utils/common'; diff --git a/networks/ethereum/src/SignerFromPrivateKey.ts b/networks/ethereum/src/signers/SignerFromPrivateKey.ts similarity index 100% rename from networks/ethereum/src/SignerFromPrivateKey.ts rename to networks/ethereum/src/signers/SignerFromPrivateKey.ts From cb487bceebeed9189e93da32dc0e76832e5b7600 Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Mon, 13 Jan 2025 10:29:08 +0800 Subject: [PATCH 08/11] return promise wait() in send function, used to get receipt from chain --- .../devnet/__tests__/noethers.test.ts | 28 +------ .../src/signers/SignerFromPrivateKey.ts | 83 ++++++++++++------- networks/ethereum/src/types/transaction.ts | 29 +++++++ 3 files changed, 88 insertions(+), 52 deletions(-) create mode 100644 networks/ethereum/src/types/transaction.ts diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts index ee7120dc..b963ebca 100644 --- a/networks/ethereum/devnet/__tests__/noethers.test.ts +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -48,26 +48,6 @@ describe('sending Tests', () => { return BigInt(hexBalance); } - // For local nodes, this might not be necessary as transactions are mined instantly - // For testnets, implement a polling mechanism or use WebSocket subscriptions - // Here, we'll poll for the receipt until it's available - async function getTransactionReceipt(txHash: string): Promise { - while (true) { - const receiptPayload = { - jsonrpc: '2.0', - method: 'eth_getTransactionReceipt', - params: [txHash], - id: 1, - }; - const receiptResp = await axios.post(RPC_URL, receiptPayload); - if (receiptResp.data.result) { - return receiptResp.data.result; - } - // Wait for a short interval before retrying - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - beforeAll(async () => { transfer = new SignerFromPrivateKey(privSender, RPC_URL); const chainId = await transfer['getChainId'](); @@ -106,7 +86,7 @@ describe('sending Tests', () => { console.log('Sender balance right before sending:', currentSenderBalance.toString()); // 4) Send transaction - const txHash = await transfer.sendLegacyTransactionAutoGasLimit( + const { txHash, wait } = await transfer.sendLegacyTransactionAutoGasLimit( receiverAddress, valueWei ); @@ -114,7 +94,7 @@ describe('sending Tests', () => { console.log('sending txHash:', txHash); - const receipt = await getTransactionReceipt(txHash); + const receipt = await wait(); expect(receipt.status).toBe('0x1'); // '0x1' indicates success // 6) Check final balances @@ -147,14 +127,14 @@ describe('sending Tests', () => { const dataHex = usdt.transfer(receiverAddress, transferAmount); - const txHash = await transfer.sendLegacyTransactionAutoGasLimit( + const { txHash, wait } = await transfer.sendLegacyTransactionAutoGasLimit( usdtAddress, 0n, dataHex ); expect(txHash).toMatch(/^0x[0-9a-fA-F]+$/); - const receipt = await getTransactionReceipt(txHash); + const receipt = await wait(); expect(receipt.status).toBe('0x1'); const afterReceiverBalance = await getUSDTBalance(receiverAddress); diff --git a/networks/ethereum/src/signers/SignerFromPrivateKey.ts b/networks/ethereum/src/signers/SignerFromPrivateKey.ts index c2f43b37..87fe6cee 100644 --- a/networks/ethereum/src/signers/SignerFromPrivateKey.ts +++ b/networks/ethereum/src/signers/SignerFromPrivateKey.ts @@ -4,6 +4,7 @@ import { keccak256 } from 'ethereum-cryptography/keccak'; import { secp256k1 } from '@noble/curves/secp256k1'; import { bytesToHex, hexToBytes, equalsBytes } from 'ethereum-cryptography/utils'; import * as rlp from 'rlp'; // Updated import +import { TransactionReceipt } from '../types/transaction'; interface JsonRpcRequest { jsonrpc: '2.0'; @@ -26,6 +27,23 @@ export class SignerFromPrivateKey { this.publicKeyUncompressed = secp256k1.getPublicKey(this.privateKey, false); } + private async pollForReceipt(txHash: string): Promise { + while (true) { + const payload = { + jsonrpc: '2.0', + method: 'eth_getTransactionReceipt', + params: [txHash], + id: 1, + }; + const resp = await axios.post(this.rpcUrl, payload); + if (resp.data.result) { + return resp.data.result as TransactionReceipt; + } + await new Promise(resolve => setTimeout(resolve, 3000)); + } + } + + /** * Helper to pad hex strings to even length. * Accepts string, bigint, or number. @@ -155,7 +173,10 @@ export class SignerFromPrivateKey { dataHex = '0x', gasPrice: bigint, gasLimit: bigint - ): Promise { + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { const fromAddr = this.getAddress(); console.log('from address in sendLegacyTransaction:', fromAddr); const nonce = await this.getNonce(); @@ -178,7 +199,7 @@ export class SignerFromPrivateKey { hexToBytes(dataHex), hexToBytes(this.toHexPadded(chainId)), new Uint8Array([]), - new Uint8Array([]) + new Uint8Array([]), ]; const unsignedTx = rlp.encode(txForSign); @@ -199,23 +220,26 @@ export class SignerFromPrivateKey { hexToBytes(dataHex), hexToBytes(vHex), r, - s + s, ]; console.log('txSigned:', txSigned); const serializedTx = rlp.encode(txSigned); const rawTxHex = '0x' + bytesToHex(serializedTx); - // console.log('Serialized Transaction:', rawTxHex); const sendPayload: JsonRpcRequest = { jsonrpc: '2.0', method: 'eth_sendRawTransaction', params: [rawTxHex], - id: 1 + id: 1, }; const resp = await axios.post(this.rpcUrl, sendPayload); if (resp.data.result) { - return resp.data.result; + const txHash = resp.data.result as string; + return { + txHash, + wait: async () => this.pollForReceipt(txHash), + }; } else if (resp.data.error) { throw new Error(JSON.stringify(resp.data.error)); } else { @@ -231,7 +255,10 @@ export class SignerFromPrivateKey { valueWei: bigint, dataHex = '0x', gasLimit: bigint - ): Promise { + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { const autoGasPrice = await this.getGasPrice(); console.log('Auto gas price:', autoGasPrice.toString()); return this.sendLegacyTransaction(to, valueWei, dataHex, autoGasPrice, gasLimit); @@ -254,7 +281,10 @@ export class SignerFromPrivateKey { maxFeePerGas: bigint, gasLimit: bigint, data: string = '0x' - ): Promise { + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { const fromAddr = this.getAddress(); const nonce = await this.getNonce(); const chainId = await this.getChainId(); @@ -267,13 +297,8 @@ export class SignerFromPrivateKey { const gasLimitHex = this.toHexPadded(gasLimit); const valueHex = this.toHexPadded(valueWei); - // EIP-1559 typed transaction: - // 0x02 + RLP([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s]) - // - // For signing, we omit [v, r, s]. - // The "accessList" can be empty for simple transfers: []. - - const accessList: any[] = []; // or define a real access list if needed + // EIP-1559 typed transaction (0x02) + const accessList: any[] = []; const txForSign = [ hexToBytes(chainIdHex), hexToBytes(nonceHex), @@ -286,28 +311,21 @@ export class SignerFromPrivateKey { accessList ]; - // According to spec, the signed message is keccak256( 0x02 || RLP( txForSign ) ) const encodedTxForSign = rlp.encode(txForSign); - - // Construct domain separator (type byte 0x02) const domainSeparator = new Uint8Array([0x02]); - - // Concatenate domain + RLP-encoded data const toBeHashed = new Uint8Array(domainSeparator.length + encodedTxForSign.length); toBeHashed.set(domainSeparator, 0); toBeHashed.set(encodedTxForSign, domainSeparator.length); const msgHash = keccak256(toBeHashed); - // Sign const { r, s, recovery } = this.signWithRecovery(msgHash); - // For typed transactions, v is commonly 27 + recovery (NOT chainId-based as in EIP-155) + // For typed transactions, v = 27 + recovery const v = 27 + recovery; const vHex = this.toHexPadded(v); - // Now the final transaction for broadcast: - // 0x02 + RLP( [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s] ) + // RLP( [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s] ) const txSigned = [ hexToBytes(chainIdHex), hexToBytes(nonceHex), @@ -338,7 +356,11 @@ export class SignerFromPrivateKey { const resp = await axios.post(this.rpcUrl, sendPayload); if (resp.data.result) { - return resp.data.result; // Transaction hash + const txHash = resp.data.result as string; + return { + txHash, + wait: async () => this.pollForReceipt(txHash), + }; } else if (resp.data.error) { throw new Error(JSON.stringify(resp.data.error)); } else { @@ -354,7 +376,10 @@ export class SignerFromPrivateKey { to: string, valueWei: bigint, dataHex = '0x' - ): Promise { + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { const gasPrice = await this.getGasPrice(); // Estimate gas limit from the node @@ -375,7 +400,10 @@ export class SignerFromPrivateKey { to: string, valueWei: bigint, data: string = '0x' - ): Promise { + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { const maxPriorityFeePerGas = await this.getMaxPriorityFeePerGas(); const maxFeePerGas = await this.getMaxFeePerGas(maxPriorityFeePerGas); @@ -402,7 +430,6 @@ export class SignerFromPrivateKey { data: data }; - // 如果 `to` 不是空字符串,则添加 `to` 字段 if (to && to !== '') { txParams.to = to; } diff --git a/networks/ethereum/src/types/transaction.ts b/networks/ethereum/src/types/transaction.ts new file mode 100644 index 00000000..b8d1766e --- /dev/null +++ b/networks/ethereum/src/types/transaction.ts @@ -0,0 +1,29 @@ +export interface TransactionLog { + address: string; + topics: string[]; + data: string; + blockNumber: string; + transactionHash: string; + transactionIndex: string; + blockHash: string; + logIndex: string; + removed: boolean; +} + +// eth_getTransactionReceipt return +export interface TransactionReceipt { + transactionHash: string; + transactionIndex: string; + blockHash: string; + blockNumber: string; + from: string; + to: string | null; + cumulativeGasUsed: string; + gasUsed: string; + contractAddress: string | null; + logs: TransactionLog[]; + logsBloom: string; + status?: string; + effectiveGasPrice?: string; + type?: string; +} \ No newline at end of file From 2183127dd42a286e18406912bf5f55a68f131b8c Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Mon, 13 Jan 2025 10:49:24 +0800 Subject: [PATCH 09/11] SignerFromBrowser --- .../ethereum/src/signers/SignerFromBrowser.ts | 267 ++++++++++++++++++ .../src/signers/SignerFromPrivateKey.ts | 1 - 2 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 networks/ethereum/src/signers/SignerFromBrowser.ts diff --git a/networks/ethereum/src/signers/SignerFromBrowser.ts b/networks/ethereum/src/signers/SignerFromBrowser.ts new file mode 100644 index 00000000..66699775 --- /dev/null +++ b/networks/ethereum/src/signers/SignerFromBrowser.ts @@ -0,0 +1,267 @@ +import { TransactionReceipt } from '../types/transaction'; + +/** + * EIP-1193 Provider interface (simplified). + * Usually, you can use 'any' or refer to the spec: + * https://eips.ethereum.org/EIPS/eip-1193#request + */ +interface EthereumProvider { + request: (args: { + method: string; + params?: any[]; + }) => Promise; +} + +/** + * SignerFromBrowser is a signer class for environments where window.ethereum is available. + * It delegates signing and broadcasting to the browser wallet (e.g., MetaMask). + */ +export class SignerFromBrowser { + private provider: EthereumProvider; + + constructor(ethereum: EthereumProvider) { + if (!ethereum || typeof ethereum.request !== 'function') { + throw new Error('No valid window.ethereum provided.'); + } + this.provider = ethereum; + } + + /** + * Poll until the transaction is mined and return the TransactionReceipt. + */ + private async pollForReceipt(txHash: string): Promise { + while (true) { + const receipt = await this.provider.request({ + method: 'eth_getTransactionReceipt', + params: [txHash], + }); + if (receipt) { + return receipt as TransactionReceipt; + } + // Wait for 3 seconds before the next check + await new Promise(resolve => setTimeout(resolve, 3000)); + } + } + + /** + * Request the currently connected account. Returns the first address from the wallet. + */ + public async getAddress(): Promise { + // Prompt user to connect accounts if not already connected + const accounts: string[] = await this.provider.request({ + method: 'eth_requestAccounts', + }); + if (!accounts || accounts.length === 0) { + throw new Error('No accounts returned by the wallet.'); + } + return accounts[0]; + } + + /** + * Fetch the nonce (transaction count) for the current address. + */ + public async getNonce(): Promise { + const address = await this.getAddress(); + const nonceHex: string = await this.provider.request({ + method: 'eth_getTransactionCount', + params: [address, 'latest'], + }); + return parseInt(nonceHex, 16); + } + + /** + * Get the chain ID from the wallet. + */ + public async getChainId(): Promise { + const chainIdHex: string = await this.provider.request({ + method: 'eth_chainId', + params: [], + }); + return parseInt(chainIdHex, 16); + } + + /** + * Get the current gas price (in wei) from the wallet's node. + */ + public async getGasPrice(): Promise { + const gasPriceHex: string = await this.provider.request({ + method: 'eth_gasPrice', + params: [], + }); + return BigInt(gasPriceHex); + } + + /** + * Get the balance (in wei) for the current address. + */ + public async getBalance(): Promise { + const address = await this.getAddress(); + const balanceHex: string = await this.provider.request({ + method: 'eth_getBalance', + params: [address, 'latest'], + }); + return BigInt(balanceHex); + } + + /** + * Send a legacy (pre-EIP1559) transaction using the browser wallet. + * The transaction is signed and broadcast by the wallet. + */ + public async sendLegacyTransaction( + to: string, + valueWei: bigint, + dataHex = '0x', + gasPrice: bigint, + gasLimit: bigint + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { + const from = await this.getAddress(); + const txParams = { + from, + to, + data: dataHex, + value: '0x' + valueWei.toString(16), + gasPrice: '0x' + gasPrice.toString(16), + gas: '0x' + gasLimit.toString(16), + }; + + // The browser wallet handles user approval, signing, and broadcasting + const txHash: string = await this.provider.request({ + method: 'eth_sendTransaction', + params: [txParams], + }); + + return { + txHash, + wait: async () => this.pollForReceipt(txHash), + }; + } + + /** + * Automatically fetch gasPrice, then send a legacy transaction. + */ + public async sendLegacyTransactionAutoGas( + to: string, + valueWei: bigint, + dataHex = '0x', + gasLimit: bigint + ) { + const autoGasPrice = await this.getGasPrice(); + return this.sendLegacyTransaction(to, valueWei, dataHex, autoGasPrice, gasLimit); + } + + /** + * Send an EIP-1559 transaction using the browser wallet. + */ + public async sendEIP1559Transaction( + to: string, + valueWei: bigint, + maxPriorityFeePerGas: bigint, + maxFeePerGas: bigint, + gasLimit: bigint, + data: string = '0x' + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { + const from = await this.getAddress(); + const txParams = { + type: '0x2', // EIP-1559 transaction + from, + to, + data, + value: '0x' + valueWei.toString(16), + maxPriorityFeePerGas: '0x' + maxPriorityFeePerGas.toString(16), + maxFeePerGas: '0x' + maxFeePerGas.toString(16), + gas: '0x' + gasLimit.toString(16), + }; + + const txHash: string = await this.provider.request({ + method: 'eth_sendTransaction', + params: [txParams], + }); + + return { + txHash, + wait: async () => this.pollForReceipt(txHash), + }; + } + + /** + * Automatically fetch maxPriorityFeePerGas, maxFeePerGas, estimate gasLimit, + * then send an EIP-1559 transaction. + */ + public async sendEIP1559TransactionAutoGasLimit( + to: string, + valueWei: bigint, + data: string = '0x' + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { + const maxPriorityFeePerGas = await this.getMaxPriorityFeePerGas(); + const maxFeePerGas = await this.getMaxFeePerGas(maxPriorityFeePerGas); + + const estimatedGasLimit = await this.estimateGas(to, valueWei, data); + // Increase the estimated gas limit by 1.5x for safety + const gasLimit = BigInt(Math.ceil(Number(estimatedGasLimit) * 1.5)); + + return this.sendEIP1559Transaction( + to, + valueWei, + maxPriorityFeePerGas, + maxFeePerGas, + gasLimit, + data + ); + } + + /** + * Estimate the gas limit for a transaction. + */ + private async estimateGas( + to: string, + valueWei: bigint, + data: string + ): Promise { + const from = await this.getAddress(); + const txParams: any = { + from, + to, + data, + value: '0x' + valueWei.toString(16), + }; + + const gasHex: string = await this.provider.request({ + method: 'eth_estimateGas', + params: [txParams], + }); + return BigInt(gasHex); + } + + /** + * Fetch maxPriorityFeePerGas from the node via the provider. + */ + private async getMaxPriorityFeePerGas(): Promise { + const priorityHex: string = await this.provider.request({ + method: 'eth_maxPriorityFeePerGas', + params: [], + }); + return BigInt(priorityHex); + } + + /** + * Calculate maxFeePerGas by adding baseFeePerGas (from feeHistory) and maxPriorityFeePerGas. + */ + private async getMaxFeePerGas(maxPriorityFeePerGas: bigint): Promise { + const feeHistory = await this.provider.request({ + method: 'eth_feeHistory', + params: [1, 'latest', []], + }); + const baseFeeHex = feeHistory?.baseFeePerGas; + const baseFeePerGas = BigInt(baseFeeHex); + return baseFeePerGas + maxPriorityFeePerGas; + } +} \ No newline at end of file diff --git a/networks/ethereum/src/signers/SignerFromPrivateKey.ts b/networks/ethereum/src/signers/SignerFromPrivateKey.ts index 87fe6cee..f1002035 100644 --- a/networks/ethereum/src/signers/SignerFromPrivateKey.ts +++ b/networks/ethereum/src/signers/SignerFromPrivateKey.ts @@ -1,4 +1,3 @@ -// EthereumTransfer.ts import axios from 'axios'; import { keccak256 } from 'ethereum-cryptography/keccak'; import { secp256k1 } from '@noble/curves/secp256k1'; From 4107c5563f8377347b88d1baf1de54f540ea79a6 Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Mon, 13 Jan 2025 11:05:16 +0800 Subject: [PATCH 10/11] sendEIP1559Transaction success --- .../devnet/__tests__/noethers.test.ts | 49 +++++++++++++++++++ .../ethereum/src/signers/SignerFromBrowser.ts | 12 ++++- .../src/signers/SignerFromPrivateKey.ts | 17 +++++-- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts index b963ebca..89e7f717 100644 --- a/networks/ethereum/devnet/__tests__/noethers.test.ts +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -145,4 +145,53 @@ describe('sending Tests', () => { expect(delta).toBe(transferAmount); }); + it('should send ETH from sender to receiver via EIP-1559, and check balances', async () => { + // 1) 先查询发送方和接收方初始余额 + const beforeSenderBalance = await signerSender.getBalance(); + const beforeReceiverBalance = await signerReceiver.getBalance(); + + console.log('Sender balance before:', beforeSenderBalance.toString()); + console.log('Receiver balance before:', beforeReceiverBalance.toString()); + + // 2) 准备转账金额 (示例:0.01 ETH) + const valueWei = 10000000000000000n; // 0.01 ETH + + // 3) 发送前再次查询发送方余额 + const currentSenderBalance = await signerSender.getBalance(); + console.log('Sender balance right before sending:', currentSenderBalance.toString()); + + // 4) 使用 EIP-1559 方式发送交易 + const { txHash, wait } = await transfer.sendEIP1559TransactionAutoGasLimit( + receiverAddress, + valueWei + ); + expect(txHash).toMatch(/^0x[0-9a-fA-F]+$/); + + console.log('EIP-1559 sending txHash:', txHash); + + // 5) 等待交易上链并获取回执 + const receipt = await wait(); + expect(receipt.status).toBe('0x1'); // '0x1' 表示交易成功 + + // 6) 检查交易后余额 + const afterSenderBalance = await signerSender.getBalance(); + const afterReceiverBalance = await signerReceiver.getBalance(); + + console.log('Sender balance after:', afterSenderBalance.toString()); + console.log('Receiver balance after:', afterReceiverBalance.toString()); + + // 7) 验证余额变化 + const senderDelta = beforeSenderBalance - afterSenderBalance; // 发送方实际减少的金额 + const receiverDelta = afterReceiverBalance - beforeReceiverBalance; // 接收方实际增加的金额 + + console.log('Sender delta:', senderDelta.toString()); + console.log('Receiver delta:', receiverDelta.toString()); + + // 接收方应增加与转账额相同 + expect(receiverDelta).toBe(valueWei); + + // 发送方损失的余额应 >= 转账额(多出的部分是 Gas 费) + expect(senderDelta).toBeGreaterThanOrEqual(valueWei); + }, 60000); + }); \ No newline at end of file diff --git a/networks/ethereum/src/signers/SignerFromBrowser.ts b/networks/ethereum/src/signers/SignerFromBrowser.ts index 66699775..abe43cb8 100644 --- a/networks/ethereum/src/signers/SignerFromBrowser.ts +++ b/networks/ethereum/src/signers/SignerFromBrowser.ts @@ -260,8 +260,18 @@ export class SignerFromBrowser { method: 'eth_feeHistory', params: [1, 'latest', []], }); - const baseFeeHex = feeHistory?.baseFeePerGas; + + const baseFeeArray = feeHistory?.baseFeePerGas; + // (可做个防护,确保确实是数组) + if (!Array.isArray(baseFeeArray) || baseFeeArray.length === 0) { + throw new Error(`Invalid feeHistory response: ${JSON.stringify(baseFeeArray)}`); + } + + // 2. 取数组最后一个元素(对应最新区块),然后转成 BigInt + const baseFeeHex = baseFeeArray[baseFeeArray.length - 1]; const baseFeePerGas = BigInt(baseFeeHex); + + // 3. 返回 baseFee + tip return baseFeePerGas + maxPriorityFeePerGas; } } \ No newline at end of file diff --git a/networks/ethereum/src/signers/SignerFromPrivateKey.ts b/networks/ethereum/src/signers/SignerFromPrivateKey.ts index f1002035..96910af5 100644 --- a/networks/ethereum/src/signers/SignerFromPrivateKey.ts +++ b/networks/ethereum/src/signers/SignerFromPrivateKey.ts @@ -320,8 +320,8 @@ export class SignerFromPrivateKey { const { r, s, recovery } = this.signWithRecovery(msgHash); - // For typed transactions, v = 27 + recovery - const v = 27 + recovery; + // For typed transactions, v = recovery + const v = recovery; const vHex = this.toHexPadded(v); // RLP( [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, v, r, s] ) @@ -478,7 +478,18 @@ export class SignerFromPrivateKey { }; const resp = await axios.post(this.rpcUrl, payload); - const baseFeePerGas = BigInt(resp.data.result.baseFeePerGas); + // 1. 先拿到 baseFeePerGas 数组 + const baseFeeArray = resp.data.result.baseFeePerGas; + // (可做个防护,确保确实是数组) + if (!Array.isArray(baseFeeArray) || baseFeeArray.length === 0) { + throw new Error(`Invalid feeHistory response: ${JSON.stringify(baseFeeArray)}`); + } + + // 2. 取数组最后一个元素(对应最新区块),然后转成 BigInt + const baseFeeHex = baseFeeArray[baseFeeArray.length - 1]; + const baseFeePerGas = BigInt(baseFeeHex); + + // 3. 返回 baseFee + tip return baseFeePerGas + maxPriorityFeePerGas; } } \ No newline at end of file From 8a7930d7241e2849267823d7a80372bbca6e751f Mon Sep 17 00:00:00 2001 From: Eason Smith Date: Mon, 13 Jan 2025 11:08:37 +0800 Subject: [PATCH 11/11] optimise code comments --- .../ethereum/devnet/__tests__/noethers.test.ts | 15 +++------------ .../ethereum/src/signers/SignerFromBrowser.ts | 3 --- .../ethereum/src/signers/SignerFromPrivateKey.ts | 4 ---- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts index 89e7f717..0065fba3 100644 --- a/networks/ethereum/devnet/__tests__/noethers.test.ts +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -146,21 +146,17 @@ describe('sending Tests', () => { }); it('should send ETH from sender to receiver via EIP-1559, and check balances', async () => { - // 1) 先查询发送方和接收方初始余额 const beforeSenderBalance = await signerSender.getBalance(); const beforeReceiverBalance = await signerReceiver.getBalance(); console.log('Sender balance before:', beforeSenderBalance.toString()); console.log('Receiver balance before:', beforeReceiverBalance.toString()); - // 2) 准备转账金额 (示例:0.01 ETH) const valueWei = 10000000000000000n; // 0.01 ETH - // 3) 发送前再次查询发送方余额 const currentSenderBalance = await signerSender.getBalance(); console.log('Sender balance right before sending:', currentSenderBalance.toString()); - // 4) 使用 EIP-1559 方式发送交易 const { txHash, wait } = await transfer.sendEIP1559TransactionAutoGasLimit( receiverAddress, valueWei @@ -169,28 +165,23 @@ describe('sending Tests', () => { console.log('EIP-1559 sending txHash:', txHash); - // 5) 等待交易上链并获取回执 const receipt = await wait(); - expect(receipt.status).toBe('0x1'); // '0x1' 表示交易成功 + expect(receipt.status).toBe('0x1'); - // 6) 检查交易后余额 const afterSenderBalance = await signerSender.getBalance(); const afterReceiverBalance = await signerReceiver.getBalance(); console.log('Sender balance after:', afterSenderBalance.toString()); console.log('Receiver balance after:', afterReceiverBalance.toString()); - // 7) 验证余额变化 - const senderDelta = beforeSenderBalance - afterSenderBalance; // 发送方实际减少的金额 - const receiverDelta = afterReceiverBalance - beforeReceiverBalance; // 接收方实际增加的金额 + const senderDelta = beforeSenderBalance - afterSenderBalance; + const receiverDelta = afterReceiverBalance - beforeReceiverBalance; console.log('Sender delta:', senderDelta.toString()); console.log('Receiver delta:', receiverDelta.toString()); - // 接收方应增加与转账额相同 expect(receiverDelta).toBe(valueWei); - // 发送方损失的余额应 >= 转账额(多出的部分是 Gas 费) expect(senderDelta).toBeGreaterThanOrEqual(valueWei); }, 60000); diff --git a/networks/ethereum/src/signers/SignerFromBrowser.ts b/networks/ethereum/src/signers/SignerFromBrowser.ts index abe43cb8..54876a5d 100644 --- a/networks/ethereum/src/signers/SignerFromBrowser.ts +++ b/networks/ethereum/src/signers/SignerFromBrowser.ts @@ -262,16 +262,13 @@ export class SignerFromBrowser { }); const baseFeeArray = feeHistory?.baseFeePerGas; - // (可做个防护,确保确实是数组) if (!Array.isArray(baseFeeArray) || baseFeeArray.length === 0) { throw new Error(`Invalid feeHistory response: ${JSON.stringify(baseFeeArray)}`); } - // 2. 取数组最后一个元素(对应最新区块),然后转成 BigInt const baseFeeHex = baseFeeArray[baseFeeArray.length - 1]; const baseFeePerGas = BigInt(baseFeeHex); - // 3. 返回 baseFee + tip return baseFeePerGas + maxPriorityFeePerGas; } } \ No newline at end of file diff --git a/networks/ethereum/src/signers/SignerFromPrivateKey.ts b/networks/ethereum/src/signers/SignerFromPrivateKey.ts index 96910af5..0e94b8ae 100644 --- a/networks/ethereum/src/signers/SignerFromPrivateKey.ts +++ b/networks/ethereum/src/signers/SignerFromPrivateKey.ts @@ -478,18 +478,14 @@ export class SignerFromPrivateKey { }; const resp = await axios.post(this.rpcUrl, payload); - // 1. 先拿到 baseFeePerGas 数组 const baseFeeArray = resp.data.result.baseFeePerGas; - // (可做个防护,确保确实是数组) if (!Array.isArray(baseFeeArray) || baseFeeArray.length === 0) { throw new Error(`Invalid feeHistory response: ${JSON.stringify(baseFeeArray)}`); } - // 2. 取数组最后一个元素(对应最新区块),然后转成 BigInt const baseFeeHex = baseFeeArray[baseFeeArray.length - 1]; const baseFeePerGas = BigInt(baseFeeHex); - // 3. 返回 baseFee + tip return baseFeePerGas + maxPriorityFeePerGas; } } \ No newline at end of file