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__/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); diff --git a/networks/ethereum/devnet/__tests__/noethers.test.ts b/networks/ethereum/devnet/__tests__/noethers.test.ts new file mode 100644 index 00000000..0065fba3 --- /dev/null +++ b/networks/ethereum/devnet/__tests__/noethers.test.ts @@ -0,0 +1,188 @@ +import { SignerFromPrivateKey } from '../../src/signers/SignerFromPrivateKey'; +// Adjust the import path as needed +import axios from 'axios'; +import { computeContractAddress } from '../../src/utils/common'; +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 +// 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'; + +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 + + 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); + } + + 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 () => { + // 1) Check initial balances + 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 transaction fields + // e.g., sending + const valueWei = 10000000000000000n; // 0.01 ETH + + // **New Code Addition: Print Sender's Balance Before Sending** + // This is to verify the sender's balance right before the transaction + const currentSenderBalance = await signerSender.getBalance(); + console.log('Sender balance right before sending:', currentSenderBalance.toString()); + + // 4) Send transaction + const { txHash, wait } = await transfer.sendLegacyTransactionAutoGasLimit( + receiverAddress, + valueWei + ); + expect(txHash).toMatch(/^0x[0-9a-fA-F]+$/); + + console.log('sending txHash:', txHash); + + const receipt = await wait(); + expect(receipt.status).toBe('0x1'); // '0x1' indicates success + + // 6) Check final balances + 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) 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 + + 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, wait } = await transfer.sendLegacyTransactionAutoGasLimit( + usdtAddress, + 0n, + dataHex + ); + expect(txHash).toMatch(/^0x[0-9a-fA-F]+$/); + + const receipt = await wait(); + 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); + }); + + it('should send ETH from sender to receiver via EIP-1559, and check balances', async () => { + const beforeSenderBalance = await signerSender.getBalance(); + const beforeReceiverBalance = await signerReceiver.getBalance(); + + console.log('Sender balance before:', beforeSenderBalance.toString()); + console.log('Receiver balance before:', beforeReceiverBalance.toString()); + + const valueWei = 10000000000000000n; // 0.01 ETH + + const currentSenderBalance = await signerSender.getBalance(); + console.log('Sender balance right before sending:', currentSenderBalance.toString()); + + const { txHash, wait } = await transfer.sendEIP1559TransactionAutoGasLimit( + receiverAddress, + valueWei + ); + expect(txHash).toMatch(/^0x[0-9a-fA-F]+$/); + + console.log('EIP-1559 sending txHash:', txHash); + + const receipt = await wait(); + expect(receipt.status).toBe('0x1'); + + const afterSenderBalance = await signerSender.getBalance(); + const afterReceiverBalance = await signerReceiver.getBalance(); + + console.log('Sender balance after:', afterSenderBalance.toString()); + console.log('Receiver balance after:', afterReceiverBalance.toString()); + + const senderDelta = beforeSenderBalance - afterSenderBalance; + const receiverDelta = afterReceiverBalance - beforeReceiverBalance; + + console.log('Sender delta:', senderDelta.toString()); + console.log('Receiver delta:', receiverDelta.toString()); + + expect(receiverDelta).toBe(valueWei); + + expect(senderDelta).toBeGreaterThanOrEqual(valueWei); + }, 60000); + +}); \ No newline at end of file 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 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/signers/SignerFromBrowser.ts b/networks/ethereum/src/signers/SignerFromBrowser.ts new file mode 100644 index 00000000..54876a5d --- /dev/null +++ b/networks/ethereum/src/signers/SignerFromBrowser.ts @@ -0,0 +1,274 @@ +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 baseFeeArray = feeHistory?.baseFeePerGas; + if (!Array.isArray(baseFeeArray) || baseFeeArray.length === 0) { + throw new Error(`Invalid feeHistory response: ${JSON.stringify(baseFeeArray)}`); + } + + const baseFeeHex = baseFeeArray[baseFeeArray.length - 1]; + 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 new file mode 100644 index 00000000..0e94b8ae --- /dev/null +++ b/networks/ethereum/src/signers/SignerFromPrivateKey.ts @@ -0,0 +1,491 @@ +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 +import { TransactionReceipt } from '../types/transaction'; + +interface JsonRpcRequest { + jsonrpc: '2.0'; + method: string; + params: any[]; + id: number; +} + +export class SignerFromPrivateKey { + 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); + } + + 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. + * 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. + */ + getAddress(): 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. + */ + public async getNonce(): Promise { + const payload: JsonRpcRequest = { + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: [this.getAddress(), '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); + } + + 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, + * 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 } { + // 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 }; + } + + /** + * Send a legacy (pre-EIP1559) transaction with gasPrice. + */ + public async sendLegacyTransaction( + to: string, + valueWei: bigint, + dataHex = '0x', + gasPrice: bigint, + gasLimit: bigint + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { + const fromAddr = this.getAddress(); + console.log('from address in sendLegacyTransaction:', fromAddr); + const nonce = await this.getNonce(); + console.log('Nonce:', nonce); + 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); + + // 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) { + 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 { + 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, + dataHex = '0x', + gasLimit: bigint + ): 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); + } + + /** + * 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: bigint, + maxPriorityFeePerGas: bigint, + maxFeePerGas: bigint, + gasLimit: bigint, + data: string = '0x' + ): Promise<{ + txHash: string; + wait: () => Promise; + }> { + const fromAddr = this.getAddress(); + const nonce = await this.getNonce(); + 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) + const accessList: any[] = []; + const txForSign = [ + hexToBytes(chainIdHex), + hexToBytes(nonceHex), + hexToBytes(maxPriorityHex), + hexToBytes(maxFeeHex), + hexToBytes(gasLimitHex), + hexToBytes(to), + hexToBytes(valueHex), + hexToBytes(data), + accessList + ]; + + const encodedTxForSign = rlp.encode(txForSign); + const domainSeparator = new Uint8Array([0x02]); + const toBeHashed = new Uint8Array(domainSeparator.length + encodedTxForSign.length); + toBeHashed.set(domainSeparator, 0); + toBeHashed.set(encodedTxForSign, domainSeparator.length); + + const msgHash = keccak256(toBeHashed); + + const { r, s, recovery } = this.signWithRecovery(msgHash); + + // 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] ) + 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) { + 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 { + 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<{ + txHash: string; + wait: () => 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<{ + txHash: string; + wait: () => 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 + }; + + 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); + + 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 baseFeeArray = resp.data.result.baseFeePerGas; + if (!Array.isArray(baseFeeArray) || baseFeeArray.length === 0) { + throw new Error(`Invalid feeHistory response: ${JSON.stringify(baseFeeArray)}`); + } + + const baseFeeHex = baseFeeArray[baseFeeArray.length - 1]; + const baseFeePerGas = BigInt(baseFeeHex); + + return baseFeePerGas + maxPriorityFeePerGas; + } +} \ No newline at end of file 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 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 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