diff --git a/.circleci/config.yml b/.circleci/config.yml index 9d3a6d29e1..4cf8751303 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,13 +10,13 @@ orbs: parameters: run-workflow-general: type: boolean - default: false + default: false run-workflow-npm-install: type: boolean - default: false + default: false run-workflow-protocol-coverage: type: boolean - default: false + default: false # When you need to force a rebuild of the node modules cache then bump this version node-modules-cache-version: type: integer @@ -910,7 +910,7 @@ workflows: version: 2 celo-monorepo-build: # Contitionally triggered - when: + when: or: [<< pipeline.parameters.run-workflow-general >>] jobs: - install_dependencies @@ -1015,7 +1015,7 @@ workflows: - lint-checks npm-install-testing-cron-workflow: # Contitionally triggered - when: + when: or: [<< pipeline.parameters.run-workflow-npm-install >>] jobs: - test-typescript-npm-package-install @@ -1023,7 +1023,7 @@ workflows: - test-contractkit-npm-package-install - test-celocli-npm-package-install protocol-testing-with-code-coverage-cron-workflow: - when: + when: or: [<< pipeline.parameters.run-workflow-protocol-coverage >>] jobs: - install_dependencies diff --git a/.github/workflows/circleci.yml b/.github/workflows/circleci.yml index 441740d5d1..168f17ce31 100644 --- a/.github/workflows/circleci.yml +++ b/.github/workflows/circleci.yml @@ -8,7 +8,10 @@ on: push: branches: - master - pull_request: + # TODO (soloseng): remove before merge + - soloseng/ganache-upgrade + # TODO(jcortejoso): Replace by pull_request + pull_request_target: branches: - master diff --git a/packages/cli/src/base.ts b/packages/cli/src/base.ts index 6ca0e7753c..b97492c5ff 100644 --- a/packages/cli/src/base.ts +++ b/packages/cli/src/base.ts @@ -201,9 +201,6 @@ export abstract class BaseCommand extends Command { const setStableTokenGas = async (stable: StableToken) => { await this.kit.setFeeCurrency(stableTokenInfos[stable].contract) - await this.kit.updateGasPriceInConnectionLayer( - await this.kit.registry.addressFor(stableTokenInfos[stable].contract) - ) } if (Object.keys(StableToken).includes(gasCurrencyConfig)) { await setStableTokenGas(StableToken[gasCurrencyConfig as keyof typeof StableToken]) diff --git a/packages/cli/src/transfer-stable-base.ts b/packages/cli/src/transfer-stable-base.ts index 293f494cdf..64c3ab0caa 100644 --- a/packages/cli/src/transfer-stable-base.ts +++ b/packages/cli/src/transfer-stable-base.ts @@ -7,6 +7,7 @@ import { BaseCommand } from './base' import { newCheckBuilder } from './utils/checks' import { displaySendTx, failWith } from './utils/cli' import { Flags } from './utils/command' +import { stableTokenInfos } from '@celo/contractkit/src/celo-tokens' export abstract class TransferStableBase extends BaseCommand { static flags = { @@ -35,7 +36,7 @@ export abstract class TransferStableBase extends BaseCommand { } catch { failWith(`The ${this._stableCurrency} token was not deployed yet`) } - await this.kit.updateGasPriceInConnectionLayer(stableToken.address) + await this.kit.setFeeCurrency(stableTokenInfos[this._stableCurrency].contract) const tx = res.flags.comment ? stableToken.transferWithComment(to, value.toFixed(), res.flags.comment) @@ -48,11 +49,7 @@ export abstract class TransferStableBase extends BaseCommand { this.kit.connection.defaultFeeCurrency === stableToken.address, async () => { const gas = await tx.txo.estimateGas({ feeCurrency: stableToken.address }) - // TODO: replace with gasPrice rpc once supported by min client version - const { gasPrice } = await this.kit.connection.fillGasPrice({ - gasPrice: '0', - feeCurrency: stableToken.address, - }) + const gasPrice = await this.kit.connection.gasPrice(stableToken.address) const gasValue = new BigNumber(gas).times(gasPrice as string) const balance = await stableToken.balanceOf(from) return balance.gte(value.plus(gasValue)) diff --git a/packages/sdk/base/src/address.ts b/packages/sdk/base/src/address.ts index d49e641a2a..cf02181ae9 100644 --- a/packages/sdk/base/src/address.ts +++ b/packages/sdk/base/src/address.ts @@ -12,7 +12,8 @@ export const normalizeAddressWith0x = (a: Address) => ensureLeading0x(a).toLower export const trimLeading0x = (input: string) => (input.startsWith('0x') ? input.slice(2) : input) -export const ensureLeading0x = (input: string) => (input.startsWith('0x') ? input : `0x${input}`) +export const ensureLeading0x = (input: string): `0x${string}` => + input.startsWith('0x') ? (input as `0x${string}`) : (`0x${input}` as const) // Turns '0xce10ce10ce10ce10ce10ce10ce10ce10ce10ce10' // into ['ce10','ce10','ce10','ce10','ce10','ce10','ce10','ce10','ce10','ce10'] diff --git a/packages/sdk/cip42changees b/packages/sdk/cip42changees new file mode 100644 index 0000000000..594ad6732b --- /dev/null +++ b/packages/sdk/cip42changees @@ -0,0 +1,46 @@ +TopLine Changes + +* Add support for CIP42 and EIP1559 Transactions +* Prefer EIP1559 if no feeCurrency and CIP42 if using feeCurrency, Old CeloLegacy transations still supported (set the gasPrice explicitly) but discouraged +* upgrade Web3 dependency + +## @celo/connect + +* gasPrice is no longer an option on config. setting 0 (or any empty value) for gasPrice in transaction will result in maxFeePerGas and maxPriorityFee per gas being set on the transaction + +* likewise gasPrice is no longer settable on the connection itself + +* (note that the gasPrice function for fetching price from node is NOT affected) + +* replace connection.fillGasPrice with connection.setFeeMarketGas + +* add inputAccessListFormatter + +* remove deprecated setGasPriceForCurrency + +* EncodedTransaction Type now supports CIp42 and EIP1559 transactions + +* inputCeloTxFormater now returns type FormattedCeloTx although apart from support for cip42 and eip1559 tx the data returned hasnt changed + +* numberToHex, ensureLeading0x, inputAddressFormatter now are typed to return `0x{string}` instead of just string + +* added parseAccessList and inputAccessListFormatter for converting accessList from json to array of array and back + +* RLPEncodedTx now types its transaction field as FormattedTransaction this is more accurate than the CeloTX type it previously had + +## kit + +* removed gasPriceSuggestionMultiplier == gas price from rpc no longer multipled by 5 + +* remove kit.fillGasPrice + +* remove updateGasPriceInConnectionLayer + +* remove kit.gasPrice + +## @celo/wallet-base + +* extractSignature now throws if the length of provided tx is not correct +* extractSignature was incorrectly typed to return Buffers for r and s values + +* ensureLeading0x now types output to be `0x{string}` diff --git a/packages/sdk/connect/src/celo-provider.test.ts b/packages/sdk/connect/src/celo-provider.test.ts index 252c5d1c34..daa2b2668d 100644 --- a/packages/sdk/connect/src/celo-provider.test.ts +++ b/packages/sdk/connect/src/celo-provider.test.ts @@ -29,8 +29,9 @@ class MockWallet implements ReadOnlyWallet { } signTransaction(_txParams: CeloTx): Promise { return Promise.resolve({ - raw: 'mock', + raw: '0xmock', tx: { + type: 'celo-legacy', nonce: 'nonce', gasPrice: 'gasPrice', gas: 'gas', @@ -193,7 +194,7 @@ describe('CeloProvider', () => { describe('but tries to use it with a different account', () => { interceptedByCeloProvider.forEach((method: string) => { - test(`fowards the call to '${method}' to the original provider`, (done) => { + test(`forwards the call to '${method}' to the original provider`, (done) => { const payload: JsonRpcPayload = { id: 0, jsonrpc: '2.0', diff --git a/packages/sdk/connect/src/connection.ts b/packages/sdk/connect/src/connection.ts index bb5fc7f455..28a3493b51 100644 --- a/packages/sdk/connect/src/connection.ts +++ b/packages/sdk/connect/src/connection.ts @@ -5,7 +5,7 @@ import { bufferToHex } from '@ethereumjs/util' import debugFactory from 'debug' import Web3 from 'web3' import { AbiCoder } from './abi-types' -import { assertIsCeloProvider, CeloProvider } from './celo-provider' +import { CeloProvider, assertIsCeloProvider } from './celo-provider' import { Address, Block, @@ -34,7 +34,7 @@ import { import { hasProperty } from './utils/provider-utils' import { getRandomId, HttpRpcCaller, RpcCaller } from './utils/rpc-caller' import { TxParamsNormalizer } from './utils/tx-params-normalizer' -import { toTxResult, TransactionResult } from './utils/tx-result' +import { TransactionResult, toTxResult } from './utils/tx-result' import { ReadOnlyWallet } from './wallet' const debugGasEstimation = debugFactory('connection:gas-estimation') @@ -42,7 +42,6 @@ const debugGasEstimation = debugFactory('connection:gas-estimation') type BN = ReturnType export interface ConnectionOptions { gasInflationFactor: number - gasPrice: string feeCurrency?: Address from?: Address } @@ -58,16 +57,11 @@ export class Connection { readonly paramsPopulator: TxParamsNormalizer rpcCaller!: RpcCaller - /** @deprecated no longer needed since gasPrice is available on node rpc */ - private currencyGasPrice: Map = new Map() - constructor(readonly web3: Web3, public wallet?: ReadOnlyWallet, handleRevert = true) { web3.eth.handleRevert = handleRevert this.config = { gasInflationFactor: 1.3, - // gasPrice:0 means the node will compute gasPrice on its own - gasPrice: '0', } const existingProvider: Provider = web3.currentProvider as Provider @@ -125,14 +119,6 @@ export class Connection { return this.config.gasInflationFactor } - set defaultGasPrice(price: number) { - this.config.gasPrice = price.toString(10) - } - - get defaultGasPrice() { - return parseInt(this.config.gasPrice, 10) - } - /** * Set the ERC20 address for the token to use to pay for transaction fees. * The ERC20 must be whitelisted for gas. @@ -224,7 +210,7 @@ export class Connection { */ sendTransaction = async (tx: CeloTx): Promise => { tx = this.fillTxDefaults(tx) - tx = this.fillGasPrice(tx) + tx = await this.setFeeMarketGas(tx) let gas = tx.gas if (gas == null) { @@ -244,7 +230,7 @@ export class Connection { tx?: Omit ): Promise => { tx = this.fillTxDefaults(tx) - tx = this.fillGasPrice(tx) + tx = await this.setFeeMarketGas(tx) let gas = tx.gas if (gas == null) { @@ -341,20 +327,27 @@ export class Connection { sendSignedTransaction = async (signedTransactionData: string): Promise => { return toTxResult(this.web3.eth.sendSignedTransaction(signedTransactionData)) } + // if neither gas price nor feeMarket fields are present set them. + async setFeeMarketGas(tx: CeloTx): Promise { + // default to the current values + const calls = [Promise.resolve(tx.maxFeePerGas), Promise.resolve(tx.maxPriorityFeePerGas)] - /** @deprecated no longer needed since gasPrice is available on node rpc */ - fillGasPrice(tx: CeloTx): CeloTx { - if (tx.feeCurrency && tx.gasPrice === '0' && this.currencyGasPrice.has(tx.feeCurrency)) { - return { - ...tx, - gasPrice: this.currencyGasPrice.get(tx.feeCurrency), + if ((isEmpty(tx.gasPrice) && isEmpty(tx.maxFeePerGas)) || isEmpty(tx.maxPriorityFeePerGas)) { + if (isEmpty(tx.maxFeePerGas)) { + calls[0] = this.gasPrice(tx.feeCurrency) + } + if (isEmpty(tx.maxPriorityFeePerGas)) { + calls[1] = this.rpcCaller.call('eth_maxPriorityFeePerGas', []).then((rpcResponse) => { + return rpcResponse.result + }) } } - return tx - } - /** @deprecated no longer needed since gasPrice is available on node rpc */ - async setGasPriceForCurrency(address: Address, gasPrice: string) { - this.currencyGasPrice.set(address, gasPrice) + const [maxFeePerGas, maxPriorityFeePerGas] = await Promise.all(calls) + return { + ...tx, + maxFeePerGas, + maxPriorityFeePerGas, + } } estimateGas = async ( @@ -508,7 +501,6 @@ export class Connection { const defaultTx: CeloTx = { from: this.config.from, feeCurrency: this.config.feeCurrency, - gasPrice: this.config.gasPrice, } return { @@ -522,3 +514,18 @@ export class Connection { this.web3.currentProvider.stop() } } + +function isEmpty(value: string | undefined | number | BN) { + return ( + value === 0 || + value === undefined || + value === null || + value === '0' || + (typeof value === 'string' && + (value.toLowerCase() === '0x' || value.toLowerCase() === '0x0')) || + Web3.utils.toBN(value.toString()).eq(Web3.utils.toBN(0)) + ) +} +export function isPresent(value: string | undefined | number | BN) { + return !isEmpty(value) +} diff --git a/packages/sdk/connect/src/types.ts b/packages/sdk/connect/src/types.ts index 4f3d4493d6..c1472a21ed 100644 --- a/packages/sdk/connect/src/types.ts +++ b/packages/sdk/connect/src/types.ts @@ -1,6 +1,11 @@ -import { PromiEvent, Transaction, TransactionConfig, TransactionReceipt } from 'web3-core' +import { + AccessList, + PromiEvent, + Transaction, + TransactionConfig, + TransactionReceipt, +} from 'web3-core' import { Contract } from 'web3-eth-contract' - export type Address = string export interface CeloParams { @@ -9,7 +14,27 @@ export interface CeloParams { gatewayFee: string } -export type CeloTx = TransactionConfig & Partial +export type AccessListRaw = Array<[string, string[]]> + +export type HexOrMissing = `0x${string}` | undefined +export interface FormattedCeloTx { + chainId: HexOrMissing + from: HexOrMissing + to: HexOrMissing + data: string | undefined + value: HexOrMissing + feeCurrency?: HexOrMissing + gatewayFeeRecipient?: HexOrMissing + gatewayFee?: HexOrMissing + gas: HexOrMissing + gasPrice?: `0x${string}` + maxFeePerGas?: `0x${string}` + maxPriorityFeePerGas?: `0x${string}` + nonce: HexOrMissing | number + accessList?: AccessListRaw +} + +export type CeloTx = TransactionConfig & Partial & { accessList?: AccessList } export interface CeloTxObject { arguments: any[] @@ -24,23 +49,49 @@ export { BlockNumber, EventLog, Log, PromiEvent, Sign } from 'web3-core' export { Block, BlockHeader, Syncing } from 'web3-eth' export { Contract, ContractSendMethod, PastEventOptions } from 'web3-eth-contract' +export type TransactionTypes = 'eip1559' | 'celo-legacy' | 'cip42' + +interface CommonTXProperties { + nonce: string + gas: string + to: string + value: string + input: string + r: string + s: string + v: string + hash: string + type: TransactionTypes +} + +interface FeeMarketAndAccessListTXProperties extends CommonTXProperties { + maxFeePerGas: string + maxPriorityFeePerGas: string + accessList?: AccessList +} + +export interface EIP1559TXProperties extends FeeMarketAndAccessListTXProperties { + type: 'eip1559' +} + +export interface CIP42TXProperties extends FeeMarketAndAccessListTXProperties { + feeCurrency: string + gatewayFeeRecipient?: string + gatewayFee?: string + type: 'cip42' +} + +export interface LegacyTXProperties extends CommonTXProperties { + gasPrice: string + feeCurrency: string + gatewayFeeRecipient: string + gatewayFee: string + type: 'celo-legacy' +} + export interface EncodedTransaction { - raw: string - tx: { - nonce: string - gasPrice: string - gas: string - feeCurrency: string - gatewayFeeRecipient: string - gatewayFee: string - to: string - value: string - input: string - r: string - s: string - v: string - hash: string - } + raw: `0x${string}` + tx: LegacyTXProperties | CIP42TXProperties | EIP1559TXProperties } export type CeloTxPending = Transaction & Partial @@ -87,6 +138,7 @@ export interface HttpProvider { } export interface RLPEncodedTx { - transaction: CeloTx - rlpEncode: string + transaction: FormattedCeloTx + rlpEncode: `0x${string}` + type: TransactionTypes } diff --git a/packages/sdk/connect/src/utils/formatter.test.ts b/packages/sdk/connect/src/utils/formatter.test.ts new file mode 100644 index 0000000000..dbd2b3a86a --- /dev/null +++ b/packages/sdk/connect/src/utils/formatter.test.ts @@ -0,0 +1,274 @@ +import { CeloTx } from '../types' +import { inputAccessListFormatter, inputCeloTxFormatter, outputCeloTxFormatter } from './formatter' + +describe('inputAccessListFormatter', () => { + test('with valid accessList', () => { + const accessList = [ + { + address: '0x0000000000000000000000000000000000000000', + storageKeys: [ + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x60fdd29ff912ce880cd3edaf9f932dc61d3dae823ea77e0323f94adb9f6a72fe', + ], + }, + ] + + expect(inputAccessListFormatter(accessList)).toEqual([ + [ + '0x0000000000000000000000000000000000000000', + [ + '0x0000000000000000000000000000000000000000000000000000000000000001', + '0x60fdd29ff912ce880cd3edaf9f932dc61d3dae823ea77e0323f94adb9f6a72fe', + ], + ], + ]) + }) +}) + +describe('inputCeloTxFormatter', () => { + const base: CeloTx = { + chainId: 42220, + nonce: 1, + gas: 1000000, + value: '0x0241', + from: '0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe', + to: '0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe', + data: '0x', + } + describe('when address does not pass checksum', () => { + ;['from', 'to', 'feeCurrency'].forEach((property) => { + test(`${property}`, () => { + const faulty = { ...base, [property]: '0x3e8' } + expect(() => inputCeloTxFormatter(faulty)).toThrowError( + `Provided address 0x3e8 is invalid, the capitalization checksum test failed` + ) + }) + }) + }) + + describe('valid celo-legacy tx', () => { + const legacy = { + ...base, + gasPrice: '0x3e8', + feeCurrency: '0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe', + } + it('formats', () => { + expect(inputCeloTxFormatter(legacy)).toMatchInlineSnapshot(` + { + "data": "0x", + "feeCurrency": "0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae", + "from": "0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae", + "gas": "0xf4240", + "gasPrice": "0x3e8", + "nonce": "0x1", + "to": "0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae", + "value": "0x241", + } + `) + }) + }) + describe('valid cip42 tx', () => { + const cip42 = { + ...base, + maxFeePerGas: '0x3e8', + maxPriorityFeePerGas: '0x3e8', + feeCurrency: '0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe', + } + it('formats', () => { + expect(inputCeloTxFormatter(cip42)).toMatchInlineSnapshot(` + { + "data": "0x", + "feeCurrency": "0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae", + "from": "0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae", + "gas": "0xf4240", + "maxFeePerGas": "0x3e8", + "maxPriorityFeePerGas": "0x3e8", + "nonce": "0x1", + "to": "0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae", + "value": "0x241", + } + `) + }) + }) + describe('valid eip1559 tx', () => { + const eip1559 = { + ...base, + maxFeePerGas: '0x3e8', + maxPriorityFeePerGas: '0x3e8', + } + it('formats', () => { + expect(inputCeloTxFormatter(eip1559)).toMatchInlineSnapshot(` + { + "data": "0x", + "from": "0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae", + "gas": "0xf4240", + "maxFeePerGas": "0x3e8", + "maxPriorityFeePerGas": "0x3e8", + "nonce": "0x1", + "to": "0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae", + "value": "0x241", + } + `) + }) + }) +}) + +describe('outputCeloTxFormatter', () => { + const base = { + nonce: '0x4', + data: '0x', + input: '0x3454645634534', + from: '0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe', + to: '0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae', + value: '0x3e8', + gas: '0x3e8', + transactionIndex: '0x1', + blockNumber: '0x3e8', + blockHash: '0xc9b9cdc2092a9d6589d96662b1fd6949611163fb3910cf8a173cd060f17702f9', + } + describe('with blockNumber', () => { + test('when valid', () => { + expect(outputCeloTxFormatter({ ...base, blockNumber: '0x1' })).toMatchInlineSnapshot(` + { + "blockHash": "0xc9b9cdc2092a9d6589d96662b1fd6949611163fb3910cf8a173cd060f17702f9", + "blockNumber": 1, + "data": "0x", + "from": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "gas": 1000, + "input": "0x3454645634534", + "nonce": 4, + "to": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "transactionIndex": 1, + "value": "1000", + } + `) + }) + test('when invalid', () => { + expect(outputCeloTxFormatter({ ...base, blockNumber: null })).toMatchInlineSnapshot(` + { + "blockHash": "0xc9b9cdc2092a9d6589d96662b1fd6949611163fb3910cf8a173cd060f17702f9", + "blockNumber": null, + "data": "0x", + "from": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "gas": 1000, + "input": "0x3454645634534", + "nonce": 4, + "to": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "transactionIndex": 1, + "value": "1000", + } + `) + }) + }) + describe('with valid celo-legacy tx', () => { + const legacy = { + ...base, + gasPrice: '0x3e8', + } + test('when valid', () => { + expect(outputCeloTxFormatter(legacy)).toMatchInlineSnapshot(` + { + "blockHash": "0xc9b9cdc2092a9d6589d96662b1fd6949611163fb3910cf8a173cd060f17702f9", + "blockNumber": 1000, + "data": "0x", + "from": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "gas": 1000, + "gasPrice": "1000", + "input": "0x3454645634534", + "nonce": 4, + "to": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "transactionIndex": 1, + "value": "1000", + } + `) + }) + }) + describe('with valid cip42 tx', () => { + const cip42 = { + ...base, + gateWayFee: '0x3e8', + feeCurrency: '0x11f4d0a3c12e86b4b5f39b213f7e19d048276dae', + maxFeePerGas: '0x3e8', + maxPriorityFeePerGas: '0x3e8', + } + test('when valid', () => { + expect(outputCeloTxFormatter(cip42)).toMatchInlineSnapshot(` + { + "blockHash": "0xc9b9cdc2092a9d6589d96662b1fd6949611163fb3910cf8a173cd060f17702f9", + "blockNumber": 1000, + "data": "0x", + "feeCurrency": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "from": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "gas": 1000, + "gateWayFee": "0x3e8", + "input": "0x3454645634534", + "maxFeePerGas": "1000", + "maxPriorityFeePerGas": "1000", + "nonce": 4, + "to": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "transactionIndex": 1, + "value": "1000", + } + `) + }) + }) + describe('with valid eip1559 tx', () => { + const eip1559 = { + ...base, + maxFeePerGas: '0x3e8', + maxPriorityFeePerGas: '0x3e8', + } + test('when valid', () => { + expect(outputCeloTxFormatter(eip1559)).toMatchInlineSnapshot(` + { + "blockHash": "0xc9b9cdc2092a9d6589d96662b1fd6949611163fb3910cf8a173cd060f17702f9", + "blockNumber": 1000, + "data": "0x", + "from": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "gas": 1000, + "input": "0x3454645634534", + "maxFeePerGas": "1000", + "maxPriorityFeePerGas": "1000", + "nonce": 4, + "to": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "transactionIndex": 1, + "value": "1000", + } + `) + }) + }) + describe('when properties are missing', () => { + test('without from', () => { + expect(outputCeloTxFormatter({ ...base, from: null })).toMatchInlineSnapshot(` + { + "blockHash": "0xc9b9cdc2092a9d6589d96662b1fd6949611163fb3910cf8a173cd060f17702f9", + "blockNumber": 1000, + "data": "0x", + "from": null, + "gas": 1000, + "input": "0x3454645634534", + "nonce": 4, + "to": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "transactionIndex": 1, + "value": "1000", + } + `) + }) + test('without to', () => { + expect(outputCeloTxFormatter({ ...base, to: null })).toMatchInlineSnapshot(` + { + "blockHash": "0xc9b9cdc2092a9d6589d96662b1fd6949611163fb3910cf8a173cd060f17702f9", + "blockNumber": 1000, + "data": "0x", + "from": "0x11f4d0A3c12e86B4b5F39B213F7E19D048276DAe", + "gas": 1000, + "input": "0x3454645634534", + "nonce": 4, + "to": null, + "transactionIndex": 1, + "value": "1000", + } + `) + }) + }) +}) diff --git a/packages/sdk/connect/src/utils/formatter.ts b/packages/sdk/connect/src/utils/formatter.ts index dfe3c07f65..aabf833151 100644 --- a/packages/sdk/connect/src/utils/formatter.ts +++ b/packages/sdk/connect/src/utils/formatter.ts @@ -3,44 +3,82 @@ import { isValidAddress, toChecksumAddress } from '@celo/utils/lib/address' import { sha3 } from '@celo/utils/lib/solidity' import BigNumber from 'bignumber.js' import { encode } from 'utf8' +import { AccessList } from 'web3-core' import { + AccessListRaw, Block, BlockHeader, BlockNumber, CeloTx, CeloTxPending, CeloTxReceipt, + FormattedCeloTx, Log, } from '../types' /** * Formats the input of a transaction and converts all values to HEX */ -export function inputCeloTxFormatter(tx: CeloTx) { - tx.from = inputAddressFormatter(tx.from?.toString()) - tx.to = inputAddressFormatter(tx.to) - tx.feeCurrency = inputAddressFormatter(tx.feeCurrency) - tx.gatewayFeeRecipient = inputAddressFormatter(tx.gatewayFeeRecipient) - - if (tx.data) { - tx.data = ensureLeading0x(tx.data) - } - - if (tx.data && !isHex(tx.data)) { +export function inputCeloTxFormatter(tx: CeloTx): FormattedCeloTx { + const { + from, + chainId, + nonce, + to, + gas, + gasPrice, + maxFeePerGas, + maxPriorityFeePerGas, + feeCurrency, + gatewayFee, + gatewayFeeRecipient, + data, + value, + accessList, + common, + chain, + hardfork, + ...rest + } = tx + const formattedTX: Partial = rest + formattedTX.from = inputAddressFormatter(from?.toString()) + formattedTX.to = inputAddressFormatter(to) + + formattedTX.gas = numberToHex(gas) + + formattedTX.value = numberToHex(value?.toString()) + formattedTX.nonce = numberToHex(nonce?.toString()) + + if (feeCurrency) { + formattedTX.feeCurrency = inputAddressFormatter(feeCurrency) + } + if (gatewayFeeRecipient) { + formattedTX.gatewayFeeRecipient = inputAddressFormatter(gatewayFeeRecipient) + } + if (gatewayFee) { + formattedTX.gatewayFee = numberToHex(gatewayFee) + } + + if (data && !isHex(data)) { throw new Error('The data field must be HEX encoded data.') + } else { + formattedTX.data = data } - tx.gas = numberToHex(tx.gas) - tx.gasPrice = numberToHex(tx.gasPrice?.toString()) - tx.value = numberToHex(tx.value?.toString()) - // @ts-ignore - nonce is defined as number, but uses as string (web3) - tx.nonce = numberToHex(tx.nonce?.toString()) - tx.gatewayFee = numberToHex(tx.gatewayFee) - - // @ts-ignore - prune undefines - Object.keys(tx).forEach((key) => tx[key] === undefined && delete tx[key]) + if (gasPrice) { + formattedTX.gasPrice = numberToHex(gasPrice.toString()) + } + if (maxFeePerGas) { + formattedTX.maxFeePerGas = numberToHex(maxFeePerGas.toString()) + } + if (maxPriorityFeePerGas) { + formattedTX.maxPriorityFeePerGas = numberToHex(maxPriorityFeePerGas.toString()) + } + if (accessList) { + formattedTX.accessList = inputAccessListFormatter(accessList) + } - return tx + return formattedTX as FormattedCeloTx } export function outputCeloTxFormatter(tx: any): CeloTxPending { @@ -52,9 +90,21 @@ export function outputCeloTxFormatter(tx: any): CeloTxPending { } tx.nonce = hexToNumber(tx.nonce) tx.gas = hexToNumber(tx.gas) - tx.gasPrice = outputBigNumberFormatter(tx.gasPrice) tx.value = outputBigNumberFormatter(tx.value) - tx.gatewayFee = outputBigNumberFormatter(tx.gatewayFee) + + if (tx.gatewayFee) { + tx.gatewayFee = outputBigNumberFormatter(tx.gatewayFee) + } + + if (tx.gasPrice) { + tx.gasPrice = outputBigNumberFormatter(tx.gasPrice) + } + if (tx.maxFeePerGas) { + tx.maxFeePerGas = outputBigNumberFormatter(tx.maxFeePerGas) + } + if (tx.maxPriorityFeePerGas) { + tx.maxPriorityFeePerGas = outputBigNumberFormatter(tx.maxPriorityFeePerGas) + } tx.to = tx.to && isValidAddress(tx.to) @@ -132,6 +182,7 @@ export function inputBlockNumberFormatter(blockNumber: BlockNumber) { : numberToHex(blockNumber.toString())! } +// TODO prune after gingerbread hardfork export function outputBlockHeaderFormatter(blockHeader: any): BlockHeader { // transform to number blockHeader.gasLimit = hexToNumber(blockHeader.gasLimit) @@ -213,12 +264,58 @@ export function outputBigNumberFormatter(hex: string): string { return new BigNumber(hex).toString(10) } -export function inputAddressFormatter(address?: string): string | undefined { +function isHash(value: string) { + return isHex(value) && value.length == 32 +} + +export function parseAccessList(accessList_: AccessListRaw | '0x'): AccessList { + const accessList: AccessList = [] + // TODO IS that true though? i think that was my mistake + // if no list was provided to original tx then it will return as "0x" + if (accessList_ === '0x') { + return accessList + } + for (let i = 0; i < accessList_.length; i++) { + const [address, storageKeys] = accessList_[i] + + throwIfInvalidAddress(address) + + accessList.push({ + address: address, + // TODO CIP42 figure out how to implement trim like viem or if needed trim(key) + storageKeys: storageKeys.map((key) => (isHash(key) ? key : /*trim*/ key)), + }) + } + return accessList +} + +function throwIfInvalidAddress(address: string) { + if (!isValidAddress(address)) throw new Error(`Invalid address: ${address}`) +} + +export function inputAccessListFormatter(accessList?: AccessList): AccessListRaw { + if (!accessList || accessList.length === 0) { + return [] + } + return accessList.reduce((acc, { address, storageKeys }) => { + throwIfInvalidAddress(address) + + storageKeys.forEach((storageKey) => { + if (storageKey.length - 2 !== 64) { + throw new Error(`Invalid storage key: ${storageKey}`) + } + }) + acc.push([address, storageKeys]) + return acc + }, [] as AccessListRaw) +} + +export function inputAddressFormatter(address?: string): `0x${string}` | undefined { if (!address || address === '0x') { return undefined } if (isValidAddress(address)) { - return ensureLeading0x(address).toLocaleLowerCase() + return ensureLeading0x(address).toLocaleLowerCase() as `0x${string}` } throw new Error(`Provided address ${address} is invalid, the capitalization checksum test failed`) } @@ -256,12 +353,12 @@ function isHexStrict(hex: string): boolean { return /^(-)?0x[0-9a-f]*$/i.test(hex) } -function numberToHex(value?: BigNumber.Value) { +function numberToHex(value?: BigNumber.Value): `0x${string}` | undefined { if (value) { const numberValue = new BigNumber(value) const result = ensureLeading0x(new BigNumber(value).toString(16)) // Seen in web3, copied just in case - return numberValue.lt(new BigNumber(0)) ? `-${result}` : result + return (numberValue.lt(new BigNumber(0)) ? `-${result}` : result) as `0x${string}` } return undefined } diff --git a/packages/sdk/connect/src/utils/tx-params-normalizer.test.ts b/packages/sdk/connect/src/utils/tx-params-normalizer.test.ts index 2bfa4b0710..2392fc6442 100644 --- a/packages/sdk/connect/src/utils/tx-params-normalizer.test.ts +++ b/packages/sdk/connect/src/utils/tx-params-normalizer.test.ts @@ -17,6 +17,8 @@ describe('TxParamsNormalizer class', () => { value: 1, gas: 1, gasPrice: 1, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, feeCurrency: undefined, gatewayFeeRecipient: '1', gatewayFee: '1', @@ -92,56 +94,50 @@ describe('TxParamsNormalizer class', () => { expect(mockGasEstimation.mock.calls.length).toBe(1) }) - /* Disabled till the coinbase issue is fixed - - test('will populate the gatewayFeeRecipient', async () => { + test('will not pop maxFeePerGas and maxPriorityFeePerGas when gasPrice is set', async () => { const celoTx: CeloTx = { ...completeCeloTx } - celoTx.gatewayFeeRecipient = undefined + celoTx.gasPrice = 1 const newCeloTx = await populator.populate(celoTx) - expect(newCeloTx.gatewayFeeRecipient).toBe('27') - expect(mockRpcCall.mock.calls.length).toBe(1) - expect(mockRpcCall.mock.calls[0][0]).toBe('eth_coinbase') + expect(newCeloTx.maxFeePerGas).toBe(undefined) + expect(newCeloTx.maxPriorityFeePerGas).toBe(undefined) }) - - test('will retrieve only once the gatewayFeeRecipient', async () => { + test('will not pop maxFeePerGas it is set', async () => { const celoTx: CeloTx = { ...completeCeloTx } - celoTx.gatewayFeeRecipient = undefined + celoTx.maxFeePerGas = 100 const newCeloTx = await populator.populate(celoTx) - expect(newCeloTx.gatewayFeeRecipient).toBe('27') - - const newCeloTx2 = await populator.populate(celoTx) - expect(newCeloTx2.gatewayFeeRecipient).toBe('27') - - expect(mockRpcCall.mock.calls.length).toBe(1) - expect(mockRpcCall.mock.calls[0][0]).toBe('eth_coinbase') + expect(newCeloTx.maxFeePerGas).toBe(100) }) - */ - - test('will populate the gas price without fee currency', async () => { + test('will not pop maxPriorityFeePerGas it is set', async () => { const celoTx: CeloTx = { ...completeCeloTx } - celoTx.gasPrice = undefined + celoTx.maxPriorityFeePerGas = 2000 const newCeloTx = await populator.populate(celoTx) - expect(newCeloTx.gasPrice).toBe('0x27') - expect(mockRpcCall.mock.calls.length).toBe(1) - expect(mockRpcCall.mock.calls[0][0]).toBe('eth_gasPrice') + expect(newCeloTx.maxPriorityFeePerGas).toBe(2000) }) - test('will populate the gas price with fee currency', async () => { + test('will populate the maxFeePerGas and maxPriorityFeePerGas without fee currency', async () => { const celoTx: CeloTx = { ...completeCeloTx } celoTx.gasPrice = undefined - celoTx.feeCurrency = 'celoMagic' + celoTx.maxFeePerGas = undefined + celoTx.maxPriorityFeePerGas = undefined + celoTx.feeCurrency = undefined const newCeloTx = await populator.populate(celoTx) - expect(newCeloTx.gasPrice).toBe('0x27') - expect(mockRpcCall.mock.calls[0]).toEqual(['eth_gasPrice', ['celoMagic']]) + expect(newCeloTx.maxFeePerGas).toBe('0x27') + expect(newCeloTx.maxPriorityFeePerGas).toBe('0x27') + expect(mockRpcCall.mock.calls[0]).toEqual(['eth_gasPrice', []]) + expect(mockRpcCall.mock.calls[1]).toEqual(['eth_maxPriorityFeePerGas', []]) }) - test('will not populate the gas price when fee currency is undefined', async () => { + test('will populate the maxFeePerGas and maxPriorityFeePerGas with fee currency', async () => { const celoTx: CeloTx = { ...completeCeloTx } celoTx.gasPrice = undefined - celoTx.feeCurrency = undefined + celoTx.maxFeePerGas = undefined + celoTx.maxPriorityFeePerGas = undefined + celoTx.feeCurrency = 'celoMagic' const newCeloTx = await populator.populate(celoTx) - expect(newCeloTx.gasPrice).toBe('0x27') - expect(mockRpcCall.mock.calls[0]).toEqual(['eth_gasPrice', []]) + expect(newCeloTx.maxFeePerGas).toBe('0x27') + expect(newCeloTx.maxPriorityFeePerGas).toBe('0x27') + expect(mockRpcCall.mock.calls[0]).toEqual(['eth_gasPrice', ['celoMagic']]) + expect(mockRpcCall.mock.calls[1]).toEqual(['eth_maxPriorityFeePerGas', []]) }) }) }) diff --git a/packages/sdk/connect/src/utils/tx-params-normalizer.ts b/packages/sdk/connect/src/utils/tx-params-normalizer.ts index 7ef8168f67..1cd48bb5af 100644 --- a/packages/sdk/connect/src/utils/tx-params-normalizer.ts +++ b/packages/sdk/connect/src/utils/tx-params-normalizer.ts @@ -10,6 +10,9 @@ function isEmpty(value: string | undefined) { value.toLowerCase() === '0x0' ) } +function isPresent(value: string | undefined) { + return !isEmpty(value) +} export class TxParamsNormalizer { private chainId: number | null = null @@ -32,8 +35,30 @@ export class TxParamsNormalizer { txParams.gas = await this.connection.estimateGas(txParams) } - if (!txParams.gasPrice || isEmpty(txParams.gasPrice.toString())) { - txParams.gasPrice = await this.connection.gasPrice(txParams.feeCurrency) + // if gasPrice is not set and maxFeePerGas is not set, set maxFeePerGas + if (isEmpty(txParams.gasPrice?.toString()) && isEmpty(txParams.maxFeePerGas?.toString())) { + const suggestedPrice = await this.connection.gasPrice(txParams.feeCurrency) + // add small buffer to suggested price like other libraries do + // const priceWithRoom = new BigNumber(suggestedPrice).times(120).dividedBy(100).toString() + txParams.maxFeePerGas = suggestedPrice + } + + // if maxFeePerGas is set make sure maxPriorityFeePerGas is also set + if ( + isPresent(txParams.maxFeePerGas?.toString()) && + isEmpty(txParams.maxPriorityFeePerGas?.toString()) + ) { + const clientMaxPriorityFeePerGas = await this.connection.rpcCaller.call( + 'eth_maxPriorityFeePerGas', + [] + ) + txParams.maxPriorityFeePerGas = clientMaxPriorityFeePerGas.result + } + + // remove gasPrice if maxFeePerGas is set + if (isPresent(txParams.gasPrice?.toString()) && isPresent(txParams.maxFeePerGas?.toString())) { + txParams.gasPrice = undefined + delete txParams.gasPrice } return txParams diff --git a/packages/sdk/contractkit/src/kit.test.ts b/packages/sdk/contractkit/src/kit.test.ts index 951e7fd056..76b04236d6 100644 --- a/packages/sdk/contractkit/src/kit.test.ts +++ b/packages/sdk/contractkit/src/kit.test.ts @@ -84,6 +84,38 @@ export function txoStub(): TransactionObjectStub { from: '0xAAFFF', }) }) + + test('works with maxFeePerGas and maxPriorityFeePerGas', async () => { + const txo = txoStub() + await kit.connection.sendTransactionObject(txo, { + maxFeePerGas: 555, + maxPriorityFeePerGas: 555, + from: '0xAAFFF', + }) + expect(txo.send).toBeCalledWith({ + gasPrice: '0', + maxFeePerGas: 555, + maxPriorityFeePerGas: 555, + // from: '0xAAFFF', + }) + }) + + test('when maxFeePerGas and maxPriorityFeePerGas and feeCurrency', async () => { + const txo = txoStub() + await kit.connection.sendTransactionObject(txo, { + maxFeePerGas: 555, + maxPriorityFeePerGas: 555, + feeCurrency: '0xe8537a3d056da446677b9e9d6c5db704eaab4787', + from: '0xAAFFF', + }) + expect(txo.send).toBeCalledWith({ + gasPrice: '0', + maxFeePerGas: 555, + maxPriorityFeePerGas: 555, + feeCurrency: '0xe8537a3d056da446677b9e9d6c5db704eaab4787', + from: '0xAAFFF', + }) + }) }) }) diff --git a/packages/sdk/contractkit/src/kit.ts b/packages/sdk/contractkit/src/kit.ts index 49ec754fc2..a13473315f 100644 --- a/packages/sdk/contractkit/src/kit.ts +++ b/packages/sdk/contractkit/src/kit.ts @@ -16,9 +16,9 @@ import { CeloContract, CeloTokenContract } from './base' import { CeloTokens, EachCeloToken } from './celo-tokens' import { ValidWrappers, WrapperCache } from './contract-cache' import { + HttpProviderOptions, ensureCurrentProvider, getWeb3ForKit, - HttpProviderOptions, setupAPIKey, } from './setupForKits' import { Web3ContractCache } from './web3-contract-cache' @@ -109,9 +109,6 @@ export class ContractKit { /** helper for interacting with CELO & stable tokens */ readonly celoTokens: CeloTokens - /** @deprecated no longer needed since gasPrice is available on node rpc */ - gasPriceSuggestionMultiplier = 5 - constructor(readonly connection: Connection) { this.registry = new AddressRegistry(connection) this._web3Contracts = new Web3ContractCache(this.registry) @@ -200,20 +197,9 @@ export class ContractKit { tokenContract === CeloContract.GoldToken ? undefined : await this.registry.addressFor(tokenContract) - if (address) { - await this.updateGasPriceInConnectionLayer(address) - } this.connection.defaultFeeCurrency = address } - /** @deprecated no longer needed since gasPrice is available on node rpc */ - async updateGasPriceInConnectionLayer(currency: Address) { - const gasPriceMinimum = await this.contracts.getGasPriceMinimum() - const rawGasPrice = await gasPriceMinimum.getGasPriceMinimum(currency) - const gasPrice = rawGasPrice.multipliedBy(this.gasPriceSuggestionMultiplier).toFixed() - await this.connection.setGasPriceForCurrency(currency, gasPrice) - } - async getEpochSize(): Promise { const blockchainParamsWrapper = await this.contracts.getBlockchainParameters() return blockchainParamsWrapper.getEpochSizeNumber() @@ -258,14 +244,6 @@ export class ContractKit { return this.connection.defaultGasInflationFactor } - set gasPrice(price: number) { - this.connection.defaultGasPrice = price - } - - get gasPrice() { - return this.connection.defaultGasPrice - } - set defaultFeeCurrency(address: Address | undefined) { this.connection.defaultFeeCurrency = address } @@ -281,13 +259,6 @@ export class ContractKit { isSyncing(): Promise { return this.connection.isSyncing() } - /** @deprecated no longer needed since gasPrice is available on node rpc */ - async fillGasPrice(tx: CeloTx): Promise { - if (tx.feeCurrency && tx.gasPrice === '0') { - await this.updateGasPriceInConnectionLayer(tx.feeCurrency) - } - return this.connection.fillGasPrice(tx) - } async sendTransaction(tx: CeloTx): Promise { return this.connection.sendTransaction(tx) diff --git a/packages/sdk/wallets/wallet-base/jest.config.js b/packages/sdk/wallets/wallet-base/jest.config.js new file mode 100644 index 0000000000..2681a75d5a --- /dev/null +++ b/packages/sdk/wallets/wallet-base/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['/src/**/?(*.)+(spec|test).ts?(x)'], +} diff --git a/packages/sdk/wallets/wallet-base/package.json b/packages/sdk/wallets/wallet-base/package.json index 5a71c68c8a..a8db3b946b 100644 --- a/packages/sdk/wallets/wallet-base/package.json +++ b/packages/sdk/wallets/wallet-base/package.json @@ -21,6 +21,9 @@ "lint": "tslint -c tslint.json --project .", "prepublishOnly": "yarn build" }, + "devDependencies": { + "viem": "~1.5.4" + }, "dependencies": { "@celo/connect": "4.1.1-dev", "@celo/base": "4.1.1-dev", diff --git a/packages/sdk/wallets/wallet-base/src/signing-utils.test.ts b/packages/sdk/wallets/wallet-base/src/signing-utils.test.ts new file mode 100644 index 0000000000..b8d5d8d17c --- /dev/null +++ b/packages/sdk/wallets/wallet-base/src/signing-utils.test.ts @@ -0,0 +1,558 @@ +import { CeloTx } from '@celo/connect' +import { normalizeAddressWith0x, privateKeyToAddress } from '@celo/utils/lib/address' +import { parseTransaction, serializeTransaction } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import Web3 from 'web3' +// import Accounts from 'web3-eth-accounts' +import { celo } from 'viem/chains' +import { + extractSignature, + getSignerFromTx, + isPriceToLow, + recoverTransaction, + rlpEncodedTx, +} from './signing-utils' +const PRIVATE_KEY1 = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' +const ACCOUNT_ADDRESS1 = normalizeAddressWith0x(privateKeyToAddress(PRIVATE_KEY1)) as `0x${string}` + +describe('rlpEncodedTx', () => { + describe('legacy', () => { + const legacyTransaction = { + feeCurrency: '0x5409ED021D9299bf6814279A6A1411A7e866A631', + from: ACCOUNT_ADDRESS1, + to: ACCOUNT_ADDRESS1, + chainId: 2, + value: Web3.utils.toWei('1000', 'ether'), + nonce: 1, + gas: '1500000000', + gasPrice: '9900000000', + data: '0xabcdef', + } + it('convert CeloTx into RLP', () => { + const transaction = { + ...legacyTransaction, + } + const result = rlpEncodedTx(transaction) + expect(result).toMatchInlineSnapshot(` + { + "rlpEncode": "0xf8490185024e1603008459682f00945409ed021d9299bf6814279a6a1411a7e866a6318080941be31a94361a391bbafb2a4ccd704f57dc04d4bb893635c9adc5dea0000083abcdef028080", + "transaction": { + "chainId": 2, + "data": "0xabcdef", + "feeCurrency": "0x5409ed021d9299bf6814279a6a1411a7e866a631", + "from": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "gas": "0x59682f00", + "gasPrice": "0x024e160300", + "gatewayFee": "0x", + "gatewayFeeRecipient": "0x", + "maxFeePerGas": "0x", + "maxPriorityFeePerGas": "0x", + "nonce": 1, + "to": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "value": "0x3635c9adc5dea00000", + }, + "type": "celo-legacy", + } + `) + }) + + describe('when chainId / gasPrice / nonce is invalid', () => { + it('chainId is not a positive number it throws error', () => { + const transaction = { + ...legacyTransaction, + chainId: -1, + } + expect(() => rlpEncodedTx(transaction)).toThrowErrorMatchingInlineSnapshot( + `"Gas, nonce or chainId is less than than 0"` + ) + }) + it('gasPrice is not a positive number it throws error', () => { + const transaction = { + ...legacyTransaction, + gasPrice: -1, + } + expect(() => rlpEncodedTx(transaction)).toThrowErrorMatchingInlineSnapshot( + `"GasPrice or maxFeePerGas or maxPriorityFeePerGas is less than than 0"` + ) + }) + it('nonce is not a positive number it throws error', () => { + const transaction = { + ...legacyTransaction, + nonce: -1, + } + expect(() => rlpEncodedTx(transaction)).toThrowErrorMatchingInlineSnapshot( + `"Gas, nonce or chainId is less than than 0"` + ) + }) + it('gas is not a positive number it throws error', () => { + const transaction = { + ...legacyTransaction, + gas: -1, + } + expect(() => rlpEncodedTx(transaction)).toThrowErrorMatchingInlineSnapshot( + `"Gas, nonce or chainId is less than than 0"` + ) + }) + }) + }) + + describe('when no gas fields are provided', () => { + it('throws an error', () => { + expect(() => rlpEncodedTx({})).toThrowErrorMatchingInlineSnapshot(`""gas" is missing"`) + }) + }) + + describe('EIP1559 / CIP42', () => { + const eip1559Transaction: CeloTx = { + from: ACCOUNT_ADDRESS1, + to: ACCOUNT_ADDRESS1, + chainId: 2, + value: Web3.utils.toWei('1000', 'ether'), + nonce: 0, + maxFeePerGas: '10', + maxPriorityFeePerGas: '99', + gas: '99', + data: '0xabcdef', + } + + describe('when maxFeePerGas is to low', () => { + it('throws an error', () => { + const transaction = { + ...eip1559Transaction, + maxFeePerGas: Web3.utils.toBN('-5'), + } + expect(() => rlpEncodedTx(transaction)).toThrowErrorMatchingInlineSnapshot( + `"GasPrice or maxFeePerGas or maxPriorityFeePerGas is less than than 0"` + ) + }) + }) + describe('when maxPriorityFeePerGas is to low', () => { + it('throws an error', () => { + const transaction = { + ...eip1559Transaction, + maxPriorityFeePerGas: Web3.utils.toBN('-5'), + } + expect(() => rlpEncodedTx(transaction)).toThrowErrorMatchingInlineSnapshot( + `"GasPrice or maxFeePerGas or maxPriorityFeePerGas is less than than 0"` + ) + }) + }) + + describe('when maxFeePerGas and maxPriorityFeePerGas and feeCurrency are provided', () => { + it('orders fields in RLP as specified by CIP42', () => { + const CIP42Transaction = { + ...eip1559Transaction, + feeCurrency: '0x5409ED021D9299bf6814279A6A1411A7e866A631', + } + const result = rlpEncodedTx(CIP42Transaction) + expect(result).toMatchInlineSnapshot(` + { + "rlpEncode": "0x7cf8400280630a63945409ed021d9299bf6814279a6a1411a7e866a6318080941be31a94361a391bbafb2a4ccd704f57dc04d4bb893635c9adc5dea0000083abcdefc0", + "transaction": { + "chainId": 2, + "data": "0xabcdef", + "feeCurrency": "0x5409ed021d9299bf6814279a6a1411a7e866a631", + "from": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "gas": "0x63", + "gasPrice": "0x", + "gatewayFee": "0x", + "gatewayFeeRecipient": "0x", + "maxFeePerGas": "0x0a", + "maxPriorityFeePerGas": "0x63", + "nonce": 0, + "to": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "value": "0x3635c9adc5dea00000", + }, + "type": "cip42", + } + `) + }) + }) + + describe('when maxFeePerGas and maxPriorityFeePerGas are provided', () => { + it('orders fields in RLP as specified by EIP1559', () => { + const CIP42Transaction = { + ...eip1559Transaction, + feeCurrency: '0x5409ED021D9299bf6814279A6A1411A7e866A631', + } + const result = rlpEncodedTx(CIP42Transaction) + expect(result).toMatchInlineSnapshot(` + { + "rlpEncode": "0x7cf8400280630a63945409ed021d9299bf6814279a6a1411a7e866a6318080941be31a94361a391bbafb2a4ccd704f57dc04d4bb893635c9adc5dea0000083abcdefc0", + "transaction": { + "chainId": 2, + "data": "0xabcdef", + "feeCurrency": "0x5409ed021d9299bf6814279a6a1411a7e866a631", + "from": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "gas": "0x63", + "gasPrice": "0x", + "gatewayFee": "0x", + "gatewayFeeRecipient": "0x", + "maxFeePerGas": "0x0a", + "maxPriorityFeePerGas": "0x63", + "nonce": 0, + "to": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "value": "0x3635c9adc5dea00000", + }, + "type": "cip42", + } + `) + }) + }) + }) + describe('compared to viem', () => { + it('matches output of viem serializeTransaction with accessList', () => { + const tx = { + type: 'eip1559' as const, + from: ACCOUNT_ADDRESS1, + to: ACCOUNT_ADDRESS1, + chainId: 2, + value: Web3.utils.toWei('1000', 'ether'), + nonce: 0, + maxFeePerGas: '1000', + maxPriorityFeePerGas: '99', + gas: '9900', + data: '0xabcdef' as const, + accessList: [ + { + address: '0x0000000000000000000000000000000000000000' as const, + storageKeys: [ + '0x0000000000000000000000000000000000000000000000000000000000000001' as const, + '0x60fdd29ff912ce880cd3edaf9f932dc61d3dae823ea77e0323f94adb9f6a72fe' as const, + ], + }, + ], + } + const txViem = { + ...tx, + gas: BigInt(tx.gas), + maxFeePerGas: BigInt(tx.maxFeePerGas), + maxPriorityFeePerGas: BigInt(tx.maxPriorityFeePerGas), + value: BigInt(tx.value), + } + const viemSerialized = serializeTransaction(txViem) + const serialized = rlpEncodedTx(tx) + + const parsedCK = parseTransaction(serialized.rlpEncode) + const parsedViem = parseTransaction(viemSerialized) + expect(parsedCK).toEqual(parsedViem) + expect(serialized.rlpEncode).toEqual(viemSerialized) + }) + it('matches output of viem serializeTransaction without accessList', () => { + const tx = { + type: 'eip1559' as const, + from: ACCOUNT_ADDRESS1, + to: ACCOUNT_ADDRESS1, + chainId: 2, + value: Web3.utils.toWei('1000', 'ether'), + nonce: 0, + maxFeePerGas: '1000', + maxPriorityFeePerGas: '99', + gas: '9900', + data: '0xabcdef' as const, + } + const txViem = { + ...tx, + gas: BigInt(tx.gas), + maxFeePerGas: BigInt(tx.maxFeePerGas), + maxPriorityFeePerGas: BigInt(tx.maxPriorityFeePerGas), + value: BigInt(tx.value), + } + const viemSerialized = serializeTransaction(txViem) + const serialized = rlpEncodedTx(tx) + + const parsedCK = parseTransaction(serialized.rlpEncode) + const parsedViem = parseTransaction(viemSerialized) + expect(parsedCK).toEqual(parsedViem) + expect(serialized.rlpEncode).toEqual(viemSerialized) + }) + }) +}) + +function ckToViem(tx: CeloTx) { + return { + ...tx, + gas: BigInt(tx.gas!), + maxFeePerGas: BigInt(tx.maxFeePerGas?.toString()!), + maxPriorityFeePerGas: BigInt(tx.maxPriorityFeePerGas?.toString()!), + value: BigInt(tx.value?.toString()!), + // @ts-expect-error + v: BigInt(tx.v?.toString()! === '0x' ? 0 : tx.v?.toString()!), + } +} + +describe('recoverTransaction', () => { + const ACCOUNT_ADDRESS1 = privateKeyToAddress(PRIVATE_KEY1) + describe('with EIP1559 transactions', () => { + test('ok', async () => { + const account = privateKeyToAccount(PRIVATE_KEY1) + const hash = await account.signTransaction({ + type: 'eip1559' as const, + from: ACCOUNT_ADDRESS1, + to: ACCOUNT_ADDRESS1 as `0x${string}`, + chainId: 2, + value: BigInt(1000), + nonce: 0, + maxFeePerGas: BigInt('1000'), + maxPriorityFeePerGas: BigInt('99'), + gas: BigInt('9900'), + data: '0xabcdef' as const, + }) + + const [transaction, signer] = recoverTransaction(hash) + expect(signer).toEqual(ACCOUNT_ADDRESS1) + expect(transaction).toMatchInlineSnapshot(` + { + "accessList": [], + "chainId": 2, + "data": "0xabcdef", + "gas": 9900, + "maxFeePerGas": 1000, + "maxPriorityFeePerGas": 99, + "nonce": 0, + "r": "0x04ddb2c87a6e0f77aa25da7439c72f978541f74fa1bd20becf2e109301d2f85c", + "s": "0x2d91eec5c0abca75d4df8322677bf43306e90172b77914494bbb7641b6dfc7e9", + "to": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "type": "eip1559", + "v": 28, + "value": 1000, + "yParity": 1, + } + `) + }) + + it('matches output of viem parseTransaction', () => { + const encodedByCK1559TX = + // from packages/sdk/wallets/wallet-local/src/local-wallet.test.ts:211 -- when calling signTransaction succeeds with eip1559 + '0x02f86d82ad5a8063630a94588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdefc080a02c61b97c545c0a59732adbc497e944818da323a508930996383751d17e0b932ea015666dce65f074f12335ab78e1912f8b83fda75f05a002943459598712e6b17c' + const [transaction, signer] = recoverTransaction(encodedByCK1559TX) + const parsed = parseTransaction(encodedByCK1559TX) + + expect(ckToViem(transaction)).toMatchObject(parsed) + expect(signer).toMatchInlineSnapshot(`"0x1Be31A94361a391bBaFB2a4CCd704F57dc04d4bb"`) + }) + it('can recover (parse) transactions signed by viem', () => { + // stolen from viems's default eip1559 test result viem/src/accounts/utils/signTransaction.test.ts + const encodedByViem1559TX = + '0x02f850018203118080825208808080c080a04012522854168b27e5dc3d5839bab5e6b39e1a0ffd343901ce1622e3d64b48f1a04e00902ae0502c4728cbf12156290df99c3ed7de85b1dbfe20b5c36931733a33' + const recovered = recoverTransaction(encodedByViem1559TX) + expect(recovered).toMatchInlineSnapshot(` + [ + { + "accessList": [], + "chainId": 1, + "data": "0x", + "gas": 21000, + "maxFeePerGas": 0, + "maxPriorityFeePerGas": 0, + "nonce": 785, + "r": "0x4012522854168b27e5dc3d5839bab5e6b39e1a0ffd343901ce1622e3d64b48f1", + "s": "0x4e00902ae0502c4728cbf12156290df99c3ed7de85b1dbfe20b5c36931733a33", + "to": "0x", + "type": "eip1559", + "v": 27, + "value": 0, + "yParity": 0, + }, + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + ] + `) + }) + }) + it('handles celo-legacy transactions', () => { + const celoLegacyTx = + '0xf88480630a80941be31a94361a391bbafb2a4ccd704f57dc04d4bb82567894588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdef83015ad8a09e121a99dc0832a9f4d1d71500b3c8a69a3c064d437c225d6292577ffcc45a71a02c5efa3c4b58953c35968e42d11d3882dacacf45402ee802824268b7cd60daff' + expect(recoverTransaction(celoLegacyTx)).toMatchInlineSnapshot(` + [ + { + "chainId": "0xad5a", + "data": "0xabcdef", + "feeCurrency": "0x", + "gas": 10, + "gasPrice": 99, + "gatewayFee": "0x5678", + "gatewayFeeRecipient": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "nonce": 0, + "to": "0x588e4b68193001e4d10928660ab4165b813717c0", + "type": "celo-legacy", + "value": "0x0de0b6b3a7640000", + }, + "0x1Be31A94361a391bBaFB2a4CCd704F57dc04d4bb", + ] + `) + }) + it('handles cip42 transactions', () => { + const cip42TX = + '0x7cf89a82ad5a8063630a94cd2a3d9f938e13cd947ec05abc7fe734df8dd826941be31a94361a391bbafb2a4ccd704f57dc04d4bb82567894588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdefc01ba0c610507b2ac3cff80dd7017419021196807d605efce0970c18cde48db33c27d1a01799477e0f601f554f0ee6f7ac21490602124801e9f7a99d9605249b90f03112' + expect(recoverTransaction(cip42TX)).toMatchInlineSnapshot(` + [ + { + "accessList": [], + "chainId": 44378, + "data": "0xabcdef", + "feeCurrency": "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826", + "gas": 10, + "gatewayFee": "0x5678", + "gatewayFeeRecipient": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "maxFeePerGas": 99, + "maxPriorityFeePerGas": 99, + "nonce": 0, + "to": "0x588e4b68193001e4d10928660ab4165b813717c0", + "type": "cip42", + "value": 1000000000000000000, + }, + "0x01035Bc38d82B345e6c383c1e1E12E0D4dB9eC0E", + ] + `) + }) + test('cip42 serialized by viem', async () => { + const account = privateKeyToAccount(PRIVATE_KEY1) + const signed = await account.signTransaction( + { + // @ts-ignore + type: 'cip42', + accessList: [], + chainId: 44378, + data: '0xabcdef', + feeCurrency: '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826', + gas: BigInt(10), + gatewayFee: BigInt('0x5678'), + gatewayFeeRecipient: '0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb', + maxFeePerGas: BigInt(99), + maxPriorityFeePerGas: BigInt(99), + nonce: 0, + to: '0x588e4b68193001e4d10928660ab4165b813717c0', + value: BigInt(1000000000000000000), + }, + { serializer: celo.serializers?.transaction } + ) + + expect(recoverTransaction(signed)).toMatchInlineSnapshot() + expect(recoverTransaction(signed)[1]).toEqual(account.address) + }) +}) + +describe('isPriceToLow', () => { + test('maxFee and maxPriorityFee are positive', () => { + expect( + isPriceToLow({ + maxFeePerGas: 1_000_000_000, + maxPriorityFeePerGas: Web3.utils.toBN('50000000000000'), + gasPrice: undefined, + }) + ).toBe(false) + }) + test('gasPrice is positive', () => { + expect( + isPriceToLow({ + gasPrice: Web3.utils.toBN('50000000000000'), + }) + ).toBe(false) + }) + test('maxFeePerGas is less than 0 but maxPriorityFeePerGas is positive ', () => { + expect(() => + isPriceToLow({ + maxFeePerGas: -1, + maxPriorityFeePerGas: 1_000_000_000, + gasPrice: undefined, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"GasPrice or maxFeePerGas or maxPriorityFeePerGas is less than than 0"` + ) + }) + test('maxPriorityFeePerGas is less than 0 but maxFeePerGas is positive ', () => { + expect(() => + isPriceToLow({ + maxFeePerGas: 1_000_000_000, + maxPriorityFeePerGas: -1_000_000_000, + gasPrice: undefined, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"GasPrice or maxFeePerGas or maxPriorityFeePerGas is less than than 0"` + ) + }) + test('gasPrice is less than 0', () => { + expect(() => + isPriceToLow({ + maxFeePerGas: '0x', + maxPriorityFeePerGas: '0x', + gasPrice: -1, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"GasPrice or maxFeePerGas or maxPriorityFeePerGas is less than than 0"` + ) + }) +}) + +describe('extractSignature', () => { + it('extracts from celo legacy txs', () => { + // packages/sdk/wallets/wallet-local/src/local-wallet.test.ts:176 (succeeds with legacy) + const extracted = extractSignature( + '0xf88480630a80941be31a94361a391bbafb2a4ccd704f57dc04d4bb82567894588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdef83015ad8a09e121a99dc0832a9f4d1d71500b3c8a69a3c064d437c225d6292577ffcc45a71a02c5efa3c4b58953c35968e42d11d3882dacacf45402ee802824268b7cd60daff' + ) + expect(extracted).toMatchInlineSnapshot(` + { + "r": "0x9e121a99dc0832a9f4d1d71500b3c8a69a3c064d437c225d6292577ffcc45a71", + "s": "0x2c5efa3c4b58953c35968e42d11d3882dacacf45402ee802824268b7cd60daff", + "v": "0x015ad8", + } + `) + }) + it('extracts from cip42 txs', () => { + // packages/sdk/wallets/wallet-local/src/local-wallet.test.ts:274 (succeeds with cip42) + const extracted = extractSignature( + '0x7cf89a82ad5a8063630a94cd2a3d9f938e13cd947ec05abc7fe734df8dd826941be31a94361a391bbafb2a4ccd704f57dc04d4bb82567894588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdefc01ba0c610507b2ac3cff80dd7017419021196807d605efce0970c18cde48db33c27d1a01799477e0f601f554f0ee6f7ac21490602124801e9f7a99d9605249b90f03112' + ) + expect(extracted).toMatchInlineSnapshot(` + { + "r": "0xc610507b2ac3cff80dd7017419021196807d605efce0970c18cde48db33c27d1", + "s": "0x1799477e0f601f554f0ee6f7ac21490602124801e9f7a99d9605249b90f03112", + "v": "0x1b", + } + `) + }) + it('extracts from eip1559 txs', () => { + // packages/sdk/wallets/wallet-local/src/local-wallet.test.ts:209 ( succeeds with eip1559) + const extracted = extractSignature( + '0x02f87082ad5a8063630a94588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdef8083015ad7a00fd2d0c579a971ebc04207d8c5ff5bb3449052f0c9e946a7146e5ae4d4ec6289a0737423de64cc81a62e700b5ca7970212aaed3d28de4dbce8b054483d361f6ff8' + ) + expect(extracted).toMatchInlineSnapshot(` + { + "r": "0x0fd2d0c579a971ebc04207d8c5ff5bb3449052f0c9e946a7146e5ae4d4ec6289", + "s": "0x737423de64cc81a62e700b5ca7970212aaed3d28de4dbce8b054483d361f6ff8", + "v": "0x015ad7", + } + `) + }) + it('fails when length is wrong', () => { + expect(() => extractSignature('0x')).toThrowErrorMatchingInlineSnapshot( + `"@extractSignature: provided transaction has 0 elements but celo-legacy txs with a signature have 12 []"` + ) + }) +}) + +describe.only('getSignerFromTx', () => { + const account = privateKeyToAccount(PRIVATE_KEY1) + test('adf', async () => { + const signed = await account.signTransaction( + { + // @ts-ignore + type: 'cip42', + accessList: [], + chainId: 44378, + data: '0xabcdef', + feeCurrency: '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826', + gas: BigInt(10), + gatewayFee: BigInt('0x5678'), + gatewayFeeRecipient: '0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb', + maxFeePerGas: BigInt(99), + maxPriorityFeePerGas: BigInt(99), + nonce: 0, + to: '0x588e4b68193001e4d10928660ab4165b813717c0', + value: BigInt(1000000000000000000), + }, + { serializer: celo.serializers?.transaction } + ) + expect(getSignerFromTx(signed)).toEqual(account.address) + }) +}) diff --git a/packages/sdk/wallets/wallet-base/src/signing-utils.ts b/packages/sdk/wallets/wallet-base/src/signing-utils.ts index c711de1195..57bda49b79 100644 --- a/packages/sdk/wallets/wallet-base/src/signing-utils.ts +++ b/packages/sdk/wallets/wallet-base/src/signing-utils.ts @@ -1,13 +1,27 @@ -import { ensureLeading0x, trimLeading0x } from '@celo/base/lib/address' -import { CeloTx, EncodedTransaction, RLPEncodedTx } from '@celo/connect' -import { inputCeloTxFormatter } from '@celo/connect/lib/utils/formatter' +import { ensureLeading0x, hexToBuffer, trimLeading0x } from '@celo/base/lib/address' +import { + CIP42TXProperties, + CeloTx, + EncodedTransaction, + LegacyTXProperties, + RLPEncodedTx, + TransactionTypes, + isPresent, +} from '@celo/connect' +import { + hexToNumber, + inputCeloTxFormatter, + parseAccessList, +} from '@celo/connect/lib/utils/formatter' import { EIP712TypedData, generateTypedDataHash } from '@celo/utils/lib/sign-typed-data-utils' import { parseSignatureWithoutPrefix } from '@celo/utils/lib/signatureUtils' import * as ethUtil from '@ethereumjs/util' import debugFactory from 'debug' +import { keccak256, publicToAddress } from 'ethereumjs-util' +import Web3 from 'web3' // TODO try to do this without web3 direct +import Accounts from 'web3-eth-accounts' // @ts-ignore-next-line eth-lib types not found import { account as Account, bytes as Bytes, hash as Hash, RLP } from 'eth-lib' - const debug = debugFactory('wallet-base:tx:sign') // Original code taken from @@ -47,122 +61,316 @@ function makeEven(hex: string) { return hex } -function signatureFormatter(signature: { v: number; r: Buffer; s: Buffer }): { +function signatureFormatter( + signature: { v: number; r: Buffer; s: Buffer }, + type: TransactionTypes +): { v: string r: string s: string } { + let v = signature.v + if (type !== 'celo-legacy') { + v = signature.v === 27 ? 0 : 1 + } return { - v: stringNumberToHex(signature.v), + v: stringNumberToHex(v), r: makeEven(trimLeadingZero(ensureLeading0x(signature.r.toString('hex')))), s: makeEven(trimLeadingZero(ensureLeading0x(signature.s.toString('hex')))), } } -function stringNumberToHex(num?: number | string): string { +function stringNumberOrBNToHex( + num?: number | string | ReturnType +): `0x${string}` { + if (typeof num === 'string' || typeof num === 'number' || num === undefined) { + return stringNumberToHex(num) + } else if (Web3.utils.isBigNumber(num)) { + return num.toString('hex') as `0x${string}` + } + return '0x' +} + +function stringNumberToHex(num?: number | string): `0x${string}` { const auxNumber = Number(num) if (num === '0x' || num === undefined || auxNumber === 0) { return '0x' } return Bytes.fromNumber(auxNumber) } - export function rlpEncodedTx(tx: CeloTx): RLPEncodedTx { + assertSerializableTX(tx) + const transaction = inputCeloTxFormatter(tx) + transaction.to = Bytes.fromNat(tx.to || '0x').toLowerCase() + transaction.nonce = Number(((tx.nonce as any) !== '0x' ? tx.nonce : 0) || 0) + transaction.data = Bytes.fromNat(tx.data || '0x').toLowerCase() + transaction.value = stringNumberToHex(tx.value?.toString()) + transaction.gas = stringNumberToHex(tx.gas) + transaction.chainId = tx.chainId || 1 + // Celo Specific + transaction.feeCurrency = Bytes.fromNat(tx.feeCurrency || '0x').toLowerCase() + transaction.gatewayFeeRecipient = Bytes.fromNat(tx.gatewayFeeRecipient || '0x').toLowerCase() + transaction.gatewayFee = stringNumberToHex(tx.gatewayFee) + + // Legacy + transaction.gasPrice = stringNumberToHex(tx.gasPrice?.toString()) + // EIP1559 / CIP42 + transaction.maxFeePerGas = stringNumberOrBNToHex(tx.maxFeePerGas) + transaction.maxPriorityFeePerGas = stringNumberOrBNToHex(tx.maxPriorityFeePerGas) + + let rlpEncode: `0x${string}` + if (isCIP42(tx)) { + // There shall be a typed transaction with the code 0x7c that has the following format: + // 0x7c || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, feecurrency, gatewayFeeRecipient, gatewayfee, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s]). + // This will be in addition to the type 0x02 transaction as specified in EIP-1559. + rlpEncode = RLP.encode([ + stringNumberToHex(transaction.chainId), + stringNumberToHex(transaction.nonce), + transaction.maxPriorityFeePerGas || '0x', + transaction.maxFeePerGas || '0x', + transaction.gas || '0x', + transaction.feeCurrency || '0x', + transaction.gatewayFeeRecipient || '0x', + transaction.gatewayFee || '0x', + transaction.to || '0x', + transaction.value || '0x', + transaction.data || '0x', + transaction.accessList || [], + ]) + return { transaction, rlpEncode: concatHex(['0x7c', rlpEncode]), type: 'cip42' } + } else if (isEIP1559(tx)) { + // https://eips.ethereum.org/EIPS/eip-1559 + // 0x02 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, destination, amount, data, access_list, signature_y_parity, signature_r, signature_s]). + rlpEncode = RLP.encode([ + stringNumberToHex(transaction.chainId), + stringNumberToHex(transaction.nonce), + transaction.maxPriorityFeePerGas || '0x', + transaction.maxFeePerGas || '0x', + transaction.gas || '0x', + transaction.to || '0x', + transaction.value || '0x', + transaction.data || '0x', + transaction.accessList || [], + ]) + return { transaction, rlpEncode: concatHex(['0x02', rlpEncode]), type: 'eip1559' } + } else { + // This order should match the order in Geth. + // https://github.com/celo-org/celo-blockchain/blob/027dba2e4584936cc5a8e8993e4e27d28d5247b8/core/types/transaction.go#L65 + rlpEncode = RLP.encode([ + stringNumberToHex(transaction.nonce), + transaction.gasPrice, + transaction.gas, + transaction.feeCurrency, + transaction.gatewayFeeRecipient, + transaction.gatewayFee, + transaction.to, + transaction.value, + transaction.data, + stringNumberToHex(transaction.chainId), + '0x', + '0x', + ]) + return { transaction, rlpEncode, type: 'celo-legacy' } + } +} + +enum TxTypeToPrefix { + 'celo-legacy' = '', + cip42 = '0x7c', + eip1559 = '0x02', +} + +function concatTypePrefixHex( + rawTransaction: string, + txType: EncodedTransaction['tx']['type'] +): `0x${string}` { + const prefix = TxTypeToPrefix[txType] + if (prefix) { + return concatHex([prefix, rawTransaction]) + } + return rawTransaction as `0x${string}` +} + +function assertSerializableTX(tx: CeloTx) { if (!tx.gas) { throw new Error('"gas" is missing') } + // ensure at least gasPrice or maxFeePerGas and maxPriorityFeePerGas are set if ( - isNullOrUndefined(tx.chainId) || - isNullOrUndefined(tx.gasPrice) || - isNullOrUndefined(tx.nonce) + !isPresent(tx.gasPrice) && + (!isPresent(tx.maxFeePerGas) || !isPresent(tx.maxPriorityFeePerGas)) + ) { + throw new Error('"gasPrice" or "maxFeePerGas" and "maxPriorityFeePerGas" are missing') + } + + // ensure that gasPrice and maxFeePerGas are not set at the same time + if ( + isPresent(tx.gasPrice) && + (isPresent(tx.maxFeePerGas) || isPresent(tx.maxPriorityFeePerGas)) ) { throw new Error( - 'One of the values "chainId", "gasPrice", or "nonce" couldn\'t be fetched: ' + - JSON.stringify({ chainId: tx.chainId, gasPrice: tx.gasPrice, nonce: tx.nonce }) + 'when "maxFeePerGas" or "maxPriorityFeePerGas" are set, "gasPrice" must not be set' ) } - if (tx.nonce! < 0 || tx.gas! < 0 || tx.gasPrice! < 0 || tx.chainId! < 0) { - throw new Error('Gas, gasPrice, nonce or chainId is lower than 0') + if (isNullOrUndefined(tx.nonce) || isNullOrUndefined(tx.chainId)) { + throw new Error( + 'One of the values "chainId" or "nonce" couldn\'t be fetched: ' + + JSON.stringify({ chainId: tx.chainId, nonce: tx.nonce }) + ) } - const transaction: CeloTx = inputCeloTxFormatter(tx) - transaction.to = Bytes.fromNat(tx.to || '0x').toLowerCase() - transaction.nonce = Number(((tx.nonce as any) !== '0x' ? tx.nonce : 0) || 0) - transaction.data = Bytes.fromNat(tx.data || '0x').toLowerCase() - transaction.value = stringNumberToHex(tx.value?.toString()) - transaction.feeCurrency = Bytes.fromNat(tx.feeCurrency || '0x').toLowerCase() - transaction.gatewayFeeRecipient = Bytes.fromNat(tx.gatewayFeeRecipient || '0x').toLowerCase() - transaction.gatewayFee = stringNumberToHex(tx.gatewayFee) - transaction.gasPrice = stringNumberToHex(tx.gasPrice?.toString()) - transaction.gas = stringNumberToHex(tx.gas) - transaction.chainId = tx.chainId || 1 - // This order should match the order in Geth. - // https://github.com/celo-org/celo-blockchain/blob/027dba2e4584936cc5a8e8993e4e27d28d5247b8/core/types/transaction.go#L65 - const rlpEncode = RLP.encode([ - stringNumberToHex(transaction.nonce), - transaction.gasPrice, - transaction.gas, - transaction.feeCurrency, - transaction.gatewayFeeRecipient, - transaction.gatewayFee, - transaction.to, - transaction.value, - transaction.data, - stringNumberToHex(transaction.chainId), - '0x', - '0x', - ]) + if (isLessThanZero(tx.nonce) || isLessThanZero(tx.gas) || isLessThanZero(tx.chainId)) { + throw new Error('Gas, nonce or chainId is less than than 0') + } + isPriceToLow(tx) +} + +export function isPriceToLow(tx: CeloTx) { + const prices = [tx.gasPrice, tx.maxFeePerGas, tx.maxPriorityFeePerGas].filter( + (price) => price != undefined + ) + let isLow = false + for (const price of prices) { + if (isLessThanZero(price)) { + throw new Error('GasPrice or maxFeePerGas or maxPriorityFeePerGas is less than than 0') + } + } + + return isLow +} + +function isEIP1559(tx: CeloTx): boolean { + return isPresent(tx.maxFeePerGas) && isPresent(tx.maxPriorityFeePerGas) +} + +function isCIP42(tx: CeloTx): boolean { + return isEIP1559(tx) && isPresent(tx.feeCurrency) +} - return { transaction, rlpEncode } +function concatHex(values: string[]): `0x${string}` { + return `0x${values.reduce((acc, x) => acc + x.replace('0x', ''), '')}` +} + +function isLessThanZero(value: CeloTx['gasPrice']) { + if (isNullOrUndefined(value)) { + return true + } + switch (typeof value) { + case 'string': + case 'number': + return Number(value) < 0 + default: + return value?.lt(Web3.utils.toBN(0)) || false + } } export async function encodeTransaction( rlpEncoded: RLPEncodedTx, signature: { v: number; r: Buffer; s: Buffer } ): Promise { - const sanitizedSignature = signatureFormatter(signature) + const sanitizedSignature = signatureFormatter(signature, rlpEncoded.type) const v = sanitizedSignature.v const r = sanitizedSignature.r const s = sanitizedSignature.s - const rawTx = RLP.decode(rlpEncoded.rlpEncode).slice(0, 9).concat([v, r, s]) + const decodedTX = prefixAwareRLPDecode(rlpEncoded.rlpEncode, rlpEncoded.type) + // for legacy tx we need to slice but for new ones we do not want to do that + const rawTx = (rlpEncoded.type === 'celo-legacy' ? decodedTX.slice(0, 9) : decodedTX).concat([ + v, + r, + s, + ]) - const rawTransaction = RLP.encode(rawTx) + // After signing, the transaction is encoded again and type prefix added + const rawTransaction = concatTypePrefixHex(RLP.encode(rawTx), rlpEncoded.type) const hash = getHashFromEncoded(rawTransaction) - const result: EncodedTransaction = { - tx: { - nonce: rlpEncoded.transaction.nonce!.toString(), - gasPrice: rlpEncoded.transaction.gasPrice!.toString(), - gas: rlpEncoded.transaction.gas!.toString(), - to: rlpEncoded.transaction.to!.toString(), - value: rlpEncoded.transaction.value!.toString(), - input: rlpEncoded.transaction.data!, + const baseTX = { + nonce: rlpEncoded.transaction.nonce!.toString(), + gas: rlpEncoded.transaction.gas!.toString(), + to: rlpEncoded.transaction.to!.toString(), + value: rlpEncoded.transaction.value!.toString(), + input: rlpEncoded.transaction.data!, + v, + r, + s, + hash, + } + let tx: Partial = baseTX + if (rlpEncoded.type === 'eip1559' || rlpEncoded.type === 'cip42') { + tx = { + ...tx, + maxFeePerGas: rlpEncoded.transaction.maxFeePerGas!.toString(), + maxPriorityFeePerGas: rlpEncoded.transaction.maxPriorityFeePerGas!.toString(), + // @ts-expect-error //TODO CIP42 fix + accessList: rlpEncoded.transaction.accessList!, + } + } + if (rlpEncoded.type === 'cip42' || rlpEncoded.type === 'celo-legacy') { + tx = { + ...tx, feeCurrency: rlpEncoded.transaction.feeCurrency!.toString(), gatewayFeeRecipient: rlpEncoded.transaction.gatewayFeeRecipient!.toString(), gatewayFee: rlpEncoded.transaction.gatewayFee!.toString(), - v, - r, - s, - hash, - }, + } as CIP42TXProperties + } + if (rlpEncoded.type === 'celo-legacy') { + tx = { + ...tx, + gasPrice: rlpEncoded.transaction.gasPrice!.toString(), + } as LegacyTXProperties + } + + return { + tx: tx as EncodedTransaction['tx'], raw: rawTransaction, + type: rlpEncoded.type, + } as EncodedTransaction +} +// new types have prefix but legacy does not +function prefixAwareRLPDecode(rlpEncode: string, type: TransactionTypes): string[] { + return type === 'celo-legacy' ? RLP.decode(rlpEncode) : RLP.decode(`0x${rlpEncode.slice(4)}`) +} + +function correctLengthWithSignatureOf(type: TransactionTypes) { + switch (type) { + case 'cip42': + return 15 + case 'celo-legacy': + case 'eip1559': + return 12 + } +} +// Based on the return type of ensureLeading0x this was not a Buffer +export function extractSignature(rawTx: string) { + const type = determineTXType(rawTx) + const rawValues = prefixAwareRLPDecode(rawTx, type) + const length = rawValues.length + console.info(type, 'length', length, 'expected', correctLengthWithSignatureOf(type), rawValues) + if (correctLengthWithSignatureOf(type) !== length) { + throw new Error( + `@extractSignature: provided transaction has ${length} elements but ${type} txs with a signature have ${correctLengthWithSignatureOf( + type + )} ${JSON.stringify(rawValues)}` + ) } - return result + return extractSignatureFromDecoded(rawValues) } -export function extractSignature(rawTx: string): { v: number; r: Buffer; s: Buffer } { - const rawValues = RLP.decode(rawTx) - let r = rawValues[10] - let s = rawValues[11] +function extractSignatureFromDecoded(rawValues: string[]) { + // signature is always (for the tx we support so far) the last three elements of the array in order v, r, s, + const v = rawValues.at(-3) + let r = rawValues.at(-2) + let s = rawValues.at(-1) + // https://github.com/wagmi-dev/viem/blob/993321689b3e2220976504e7e170fe47731297ce/src/utils/transaction/parseTransaction.ts#L281 // Account.recover cannot handle canonicalized signatures // A canonicalized signature may have the first byte removed if its value is 0 - r = ensureLeading0x(trimLeading0x(r).padStart(64, '0')) - s = ensureLeading0x(trimLeading0x(s).padStart(64, '0')) + r = ensureLeading0x(trimLeading0x(r as string).padStart(64, '0')) + s = ensureLeading0x(trimLeading0x(s as string).padStart(64, '0')) return { - v: rawValues[9], + v, r, s, } @@ -171,34 +379,173 @@ export function extractSignature(rawTx: string): { v: number; r: Buffer; s: Buff // Recover transaction and sender address from a raw transaction. // This is used for testing. export function recoverTransaction(rawTx: string): [CeloTx, string] { - const rawValues = RLP.decode(rawTx) - debug('signing-utils@recoverTransaction: values are %s', rawValues) - const recovery = Bytes.toNumber(rawValues[9]) - // tslint:disable-next-line:no-bitwise - const chainId = Bytes.fromNumber((recovery - 35) >> 1) - const celoTx: CeloTx = { - nonce: rawValues[0].toLowerCase() === '0x' ? 0 : parseInt(rawValues[0], 16), - gasPrice: rawValues[1].toLowerCase() === '0x' ? 0 : parseInt(rawValues[1], 16), - gas: rawValues[2].toLowerCase() === '0x' ? 0 : parseInt(rawValues[2], 16), - feeCurrency: rawValues[3], - gatewayFeeRecipient: rawValues[4], - gatewayFee: rawValues[5], - to: rawValues[6], - value: rawValues[7], - data: rawValues[8], + if (!rawTx.startsWith('0x')) { + throw new Error('rawTx must start with 0x') + } + switch (determineTXType(rawTx)) { + case 'cip42': + return recoverTransactionCIP42(rawTx as `0x${string}`) + case 'eip1559': + return recoverTransactionEIP1559(rawTx as `0x${string}`) + default: + const rawValues = RLP.decode(rawTx) + debug('signing-utils@recoverTransaction: values are %s', rawValues) + const recovery = Bytes.toNumber(rawValues[9]) + // tslint:disable-next-line:no-bitwise + const chainId = Bytes.fromNumber((recovery - 35) >> 1) + const celoTx: CeloTx = { + type: 'celo-legacy', + nonce: rawValues[0].toLowerCase() === '0x' ? 0 : parseInt(rawValues[0], 16), + gasPrice: rawValues[1].toLowerCase() === '0x' ? 0 : parseInt(rawValues[1], 16), + gas: rawValues[2].toLowerCase() === '0x' ? 0 : parseInt(rawValues[2], 16), + feeCurrency: rawValues[3], + gatewayFeeRecipient: rawValues[4], + gatewayFee: rawValues[5], + to: rawValues[6], + value: rawValues[7], + data: rawValues[8], + chainId, + } + const { r, v, s } = extractSignatureFromDecoded(rawValues) + const signature = Account.encodeSignature([v, r, s]) + const extraData = recovery < 35 ? [] : [chainId, '0x', '0x'] + // TODO CIP42 For if is slice taking off the v r s values, then that should probably happen for all types + const signingData = rawValues.slice(0, 9).concat(extraData) + const signingDataHex = RLP.encode(signingData) + const signer = Account.recover(getHashFromEncoded(signingDataHex), signature) + return [celoTx, signer] + } +} + +const TRANSACTION_TYPE = '0x7c' +const TRANSACTION_TYPE_BUFFER = Buffer.from(TRANSACTION_TYPE.padStart(2, '0'), 'hex') + +//inspired by @ethereumjs/tx +function getPublicKeyofSignerFromTx(transactionArray: string[]) { + const base = transactionArray.slice(-3) + const message = Buffer.concat([TRANSACTION_TYPE_BUFFER, Buffer.from(RLP.encode(base))]) + const msgHash = keccak256(message) + + const { v, r, s } = extractSignatureFromDecoded(transactionArray) + + try { + return ethUtil.ecrecover( + msgHash, + v === '0x' || v === undefined ? BigInt(27) : BigInt(v!) + BigInt(27), // Recover the 27 which was stripped from ecsign + hexToBuffer(r!), + hexToBuffer(s!) + ) + } catch (e: any) { + throw new Error(e) + } +} + +export function getSignerFromTx(serializedTransaction: string): string { + const transactionArray: any[] = RLP.decode(`0x${serializedTransaction.slice(4)}`) + const signer = getPublicKeyofSignerFromTx(transactionArray) + const address = publicToAddress(signer) + return `0x` + address.toString('hex') +} + +function determineTXType(serializedTransaction: string): TransactionTypes { + // TODO CIP42 is this slice ok? + const prefix = serializedTransaction.slice(0, 4) + if (prefix === '0x02') { + return 'eip1559' + } else if (prefix === '0x7c') { + return 'cip42' + } + console.warn('Unknown transaction type', prefix) + return 'celo-legacy' +} + +function recoverTransactionCIP42(serializedTransaction: `0x${string}`): [CeloTx, string] { + const transactionArray: any[] = RLP.decode(`0x${serializedTransaction.slice(4)}`) + debug('signing-utils@recoverTransactionCIP42: values are %s', transactionArray) + if (transactionArray.length !== 15 && transactionArray.length !== 12) { + throw new Error( + `Invalid transaction length for type CIP42: ${transactionArray.length} instead of 15 or 12. array: ${transactionArray}` + ) + } + const [ + chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gas, + feeCurrency, + gatewayFeeRecipient, + gatewayFee, + to, + value, + data, + accessList, + ] = transactionArray + + const celoTX: CeloTx = { + type: 'cip42', + nonce: nonce.toLowerCase() === '0x' ? 0 : parseInt(nonce, 16), + maxPriorityFeePerGas: + maxPriorityFeePerGas.toLowerCase() === '0x' ? 0 : parseInt(maxPriorityFeePerGas, 16), + maxFeePerGas: maxFeePerGas.toLowerCase() === '0x' ? 0 : parseInt(maxFeePerGas, 16), + gas: gas.toLowerCase() === '0x' ? 0 : parseInt(gas, 16), + feeCurrency, + gatewayFeeRecipient, + gatewayFee, + to, + value: value.toLowerCase() === '0x' ? 0 : parseInt(value, 16), + data, + chainId: chainId.toLowerCase() === '0x' ? 0 : parseInt(chainId, 16), + accessList: parseAccessList(accessList), + } + // const web3Account = new Accounts() + // const signer = web3Account.recoverTransaction(serializedTransaction) + const signer = getSignerFromTx(serializedTransaction) + return [celoTX, signer] +} + +function recoverTransactionEIP1559(serializedTransaction: `0x${string}`): [CeloTx, string] { + const transactionArray = RLP.decode(`0x${serializedTransaction.slice(4)}`) + debug('signing-utils@recoverTransactionEIP1559: values are %s', transactionArray) + + const [ chainId, + nonce, + maxPriorityFeePerGas, + maxFeePerGas, + gas, + to, + value, + data, + accessList, + vRaw, + r, + s, + ] = transactionArray + + // TODO CIP42 do this for cip42 too also should this also happen when extracting signer? + const v = vRaw === '0x' || hexToNumber(vRaw) === 0 ? 27 : 28 + + const celoTx: CeloTx & { v: any; s: any; r: any; yParity: 0 | 1 } = { + type: 'eip1559', + nonce: nonce.toLowerCase() === '0x' ? 0 : parseInt(nonce, 16), + gas: gas.toLowerCase() === '0x' ? 0 : parseInt(gas, 16), + maxPriorityFeePerGas: + maxPriorityFeePerGas.toLowerCase() === '0x' ? 0 : parseInt(maxPriorityFeePerGas, 16), + maxFeePerGas: maxFeePerGas.toLowerCase() === '0x' ? 0 : parseInt(maxFeePerGas, 16), + to: to, + value: value.toLowerCase() === '0x' ? 0 : parseInt(value, 16), + data: data, + chainId: chainId.toLowerCase() === '0x' ? 0 : parseInt(chainId, 16), + accessList: parseAccessList(accessList), + v, + r, + s, + yParity: v === 27 ? 0 : 1, } - let r = rawValues[10] - let s = rawValues[11] - // Account.recover cannot handle canonicalized signatures - // A canonicalized signature may have the first byte removed if its value is 0 - r = ensureLeading0x(trimLeading0x(r).padStart(64, '0')) - s = ensureLeading0x(trimLeading0x(s).padStart(64, '0')) - const signature = Account.encodeSignature([rawValues[9], r, s]) - const extraData = recovery < 35 ? [] : [chainId, '0x', '0x'] - const signingData = rawValues.slice(0, 9).concat(extraData) - const signingDataHex = RLP.encode(signingData) - const signer = Account.recover(getHashFromEncoded(signingDataHex), signature) + const web3Account = new Accounts() + const signer = web3Account.recoverTransaction(serializedTransaction) + return [celoTx, signer] } @@ -236,6 +583,7 @@ export function verifySignatureWithoutPrefix( export function decodeSig(sig: any) { const [v, r, s] = Account.decodeSignature(sig) + console.info('v, r, s', v, r, s) return { v: parseInt(v, 16), r: ethUtil.toBuffer(r) as Buffer, diff --git a/packages/sdk/wallets/wallet-base/src/wallet-base.ts b/packages/sdk/wallets/wallet-base/src/wallet-base.ts index 9d0cd2575a..7c79c34e89 100644 --- a/packages/sdk/wallets/wallet-base/src/wallet-base.ts +++ b/packages/sdk/wallets/wallet-base/src/wallet-base.ts @@ -77,13 +77,20 @@ export abstract class WalletBase implements ReadOnlyWall throw new Error('No transaction object given!') } const rlpEncoded = rlpEncodedTx(txParams) - const addToV = chainIdTransformationForSigning(txParams.chainId!) + // TODO CIP42 check this probably only needed in legacy txs + // when i removed (set to 0) i got a failure + const addToV = + rlpEncoded.type === 'celo-legacy' ? chainIdTransformationForSigning(txParams.chainId!) : 27 // Get the signer from the 'from' field const fromAddress = txParams.from!.toString() const signer = this.getSigner(fromAddress) const signature = await signer!.signTransaction(addToV, rlpEncoded) - + console.info( + `Signed tx ${rlpEncoded.type} with signature ${JSON.stringify(signature)}`, + 'fromAddress', + fromAddress + ) return encodeTransaction(rlpEncoded, signature) } diff --git a/packages/sdk/wallets/wallet-base/tsconfig.json b/packages/sdk/wallets/wallet-base/tsconfig.json index e6a754088b..51debe8913 100644 --- a/packages/sdk/wallets/wallet-base/tsconfig.json +++ b/packages/sdk/wallets/wallet-base/tsconfig.json @@ -5,5 +5,6 @@ "outDir": "lib" }, "include": ["src", "types"], + "exclude": ["**/*.test.ts"], "references": [{ "path": "../../utils" }] } diff --git a/packages/sdk/wallets/wallet-local/package.json b/packages/sdk/wallets/wallet-local/package.json index 146ae911be..fbdee10aa7 100644 --- a/packages/sdk/wallets/wallet-local/package.json +++ b/packages/sdk/wallets/wallet-local/package.json @@ -17,7 +17,7 @@ "build": "tsc -b .", "clean": "tsc -b . --clean", "docs": "typedoc", - "test": "jest --runInBand", + "test": "jest", "lint": "tslint -c tslint.json --project .", "prepublishOnly": "yarn build" }, @@ -29,7 +29,8 @@ "@ethereumjs/util": "8.0.5" }, "devDependencies": { - "web3": "1.10.0" + "web3": "1.10.0", + "viem": "~1.5.4" }, "engines": { "node": ">=8.14.2" diff --git a/packages/sdk/wallets/wallet-local/src/local-signer.ts b/packages/sdk/wallets/wallet-local/src/local-signer.ts index 0f24c8241a..27a3adbb51 100644 --- a/packages/sdk/wallets/wallet-local/src/local-signer.ts +++ b/packages/sdk/wallets/wallet-local/src/local-signer.ts @@ -28,6 +28,7 @@ export class LocalSigner implements Signer { ): Promise<{ v: number; r: Buffer; s: Buffer }> { const hash = getHashFromEncoded(encodedTx.rlpEncode) const signature = Account.makeSigner(addToV)(hash, this.privateKey) + console.info('Signed tx with signature', JSON.stringify(signature)) return decodeSig(signature) } diff --git a/packages/sdk/wallets/wallet-local/src/local-wallet.test.ts b/packages/sdk/wallets/wallet-local/src/local-wallet.test.ts index 4f34b5e927..b7cb113f3c 100644 --- a/packages/sdk/wallets/wallet-local/src/local-wallet.test.ts +++ b/packages/sdk/wallets/wallet-local/src/local-wallet.test.ts @@ -8,6 +8,8 @@ import { import { Encrypt } from '@celo/utils/lib/ecies' import { verifySignature } from '@celo/utils/lib/signatureUtils' import { recoverTransaction, verifyEIP712TypedDataSigner } from '@celo/wallet-base' +import { TransactionSerializableEIP1559, parseTransaction } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' import Web3 from 'web3' import { LocalWallet } from './local-wallet' @@ -126,25 +128,37 @@ describe('Local wallet class', () => { }) test('fails calling signTransaction', async () => { - await expect(wallet.signTransaction(celoTransaction)).rejects.toThrowError() + await expect( + wallet.signTransaction(celoTransaction) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not find address 0x588e4b68193001e4d10928660ab4165b813717c0"` + ) }) test('fails calling signPersonalMessage', async () => { const hexStr: string = '0xa1' - await expect(wallet.signPersonalMessage(unknownAddress, hexStr)).rejects.toThrowError() + await expect( + wallet.signPersonalMessage(unknownAddress, hexStr) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not find address 0x588e4b68193001e4d10928660ab4165b813717c0"` + ) }) test('fails calling signTypedData', async () => { - await expect(wallet.signTypedData(unknownAddress, TYPED_DATA)).rejects.toThrowError() + await expect( + wallet.signTypedData(unknownAddress, TYPED_DATA) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Could not find address 0x588e4b68193001e4d10928660ab4165b813717c0"` + ) }) }) describe('using a known address', () => { describe('when calling signTransaction', () => { - let celoTransaction: CeloTx + let celoTransactionWithGasPrice: CeloTx beforeEach(() => { - celoTransaction = { + celoTransactionWithGasPrice = { from: knownAddress, to: otherAddress, chainId: CHAIN_ID, @@ -155,16 +169,140 @@ describe('Local wallet class', () => { feeCurrency: '0x', gatewayFeeRecipient: FEE_ADDRESS, gatewayFee: '0x5678', - data: '0xabcdef', + data: '0xabcdef' as const, } }) - test('succeeds', async () => { - await expect(wallet.signTransaction(celoTransaction)).resolves.not.toBeUndefined() + test('succeeds with legacy', async () => { + await expect(wallet.signTransaction(celoTransactionWithGasPrice)).resolves + .toMatchInlineSnapshot(` + { + "raw": "0xf88480630a80941be31a94361a391bbafb2a4ccd704f57dc04d4bb82567894588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdef83015ad8a09e121a99dc0832a9f4d1d71500b3c8a69a3c064d437c225d6292577ffcc45a71a02c5efa3c4b58953c35968e42d11d3882dacacf45402ee802824268b7cd60daff", + "tx": { + "feeCurrency": "0x", + "gas": "0x0a", + "gasPrice": "0x63", + "gatewayFee": "0x5678", + "gatewayFeeRecipient": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "hash": "0xd24898ee3f68caa01fe065784453db7360bf783060fcbd18033f9d254ab8b082", + "input": "0xabcdef", + "nonce": "0", + "r": "0x9e121a99dc0832a9f4d1d71500b3c8a69a3c064d437c225d6292577ffcc45a71", + "s": "0x2c5efa3c4b58953c35968e42d11d3882dacacf45402ee802824268b7cd60daff", + "to": "0x588e4b68193001e4d10928660ab4165b813717c0", + "v": "0x015ad8", + "value": "0x0de0b6b3a7640000", + }, + "type": "celo-legacy", + } + `) + }) + + test('succeeds with eip1559', async () => { + const transaction1559 = { + ...celoTransactionWithGasPrice, + gasPrice: undefined, + feeCurrency: undefined, + maxFeePerGas: '99', + maxPriorityFeePerGas: '99', + } + await expect(wallet.signTransaction(transaction1559)).resolves.toMatchInlineSnapshot(` + { + "raw": "0x02f86d82ad5a8063630a94588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdefc080a02c61b97c545c0a59732adbc497e944818da323a508930996383751d17e0b932ea015666dce65f074f12335ab78e1912f8b83fda75f05a002943459598712e6b17c", + "tx": { + "accessList": undefined, + "gas": "0x0a", + "hash": "0xc8be0a99b8f133e843f6824d00db12b89d94e0df0cc28899021edc8924b7b2ba", + "input": "0xabcdef", + "maxFeePerGas": "0x63", + "maxPriorityFeePerGas": "0x63", + "nonce": "0", + "r": "0x2c61b97c545c0a59732adbc497e944818da323a508930996383751d17e0b932e", + "s": "0x15666dce65f074f12335ab78e1912f8b83fda75f05a002943459598712e6b17c", + "to": "0x588e4b68193001e4d10928660ab4165b813717c0", + "v": "0x", + "value": "0x0de0b6b3a7640000", + }, + "type": "eip1559", + } + `) + }) + + test('matches behavior of viem 1559', async () => { + const account = privateKeyToAccount(PRIVATE_KEY2) + const wallet2 = new LocalWallet() + // wallet 1 uses a private key that does not start with 0x which doesnt work for viem + wallet2.addAccount(PRIVATE_KEY2) + + const transaction1559 = { + ...celoTransactionWithGasPrice, + from: ACCOUNT_ADDRESS2, + to: otherAddress, + gasPrice: undefined, + feeCurrency: undefined, + maxFeePerGas: '99', + maxPriorityFeePerGas: '99', + data: celoTransactionWithGasPrice.data as `0x${string}`, + } + const transaction1559Viem: TransactionSerializableEIP1559 = { + ...transaction1559, + type: 'eip1559', + gas: BigInt(transaction1559.gas as string), + to: transaction1559.to as `0x${string}`, + value: BigInt(transaction1559.value as string), + maxFeePerGas: BigInt(transaction1559.maxFeePerGas as string), + maxPriorityFeePerGas: BigInt(transaction1559.maxPriorityFeePerGas as string), + accessList: undefined, + chainId: celoTransactionWithGasPrice.chainId as number, + } + const signedTransaction = await wallet2.signTransaction(transaction1559) + const viemSignedTransaction = await account.signTransaction(transaction1559Viem) + + // TODO CIP42 wth parsing equals but recover doenst and the rawtx strings also dont match + expect(parseTransaction(signedTransaction.raw)).toEqual( + parseTransaction(viemSignedTransaction) + ) + // FAILS expect(recoverTransaction(signedTransaction.raw)).toEqual(recoverTransaction(viemSignedTransaction)) + // FAILS expect(signedTransaction.raw).toEqual(viemSignedTransaction) + }) + + test('succeeds with cip42', async () => { + const transaction42 = { + ...celoTransactionWithGasPrice, + gasPrice: undefined, + maxFeePerGas: '99', + maxPriorityFeePerGas: '99', + feeCurrency: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + } + await expect(wallet.signTransaction(transaction42)).resolves.toMatchInlineSnapshot(` + { + "raw": "0x7cf89a82ad5a8063630a94cd2a3d9f938e13cd947ec05abc7fe734df8dd826941be31a94361a391bbafb2a4ccd704f57dc04d4bb82567894588e4b68193001e4d10928660ab4165b813717c0880de0b6b3a764000083abcdefc080a0c610507b2ac3cff80dd7017419021196807d605efce0970c18cde48db33c27d1a01799477e0f601f554f0ee6f7ac21490602124801e9f7a99d9605249b90f03112", + "tx": { + "accessList": undefined, + "feeCurrency": "0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826", + "gas": "0x0a", + "gatewayFee": "0x5678", + "gatewayFeeRecipient": "0x1be31a94361a391bbafb2a4ccd704f57dc04d4bb", + "hash": "0x7afcef8db391ff574b7f9c9205399b8ab094fc9fc8afbfb881204cbaaf093365", + "input": "0xabcdef", + "maxFeePerGas": "0x63", + "maxPriorityFeePerGas": "0x63", + "nonce": "0", + "r": "0xc610507b2ac3cff80dd7017419021196807d605efce0970c18cde48db33c27d1", + "s": "0x1799477e0f601f554f0ee6f7ac21490602124801e9f7a99d9605249b90f03112", + "to": "0x588e4b68193001e4d10928660ab4165b813717c0", + "v": "0x", + "value": "0x0de0b6b3a7640000", + }, + "type": "cip42", + } + `) }) test('with same signer', async () => { - const signedTx: EncodedTransaction = await wallet.signTransaction(celoTransaction) + const signedTx: EncodedTransaction = await wallet.signTransaction( + celoTransactionWithGasPrice + ) const [, recoveredSigner] = recoverTransaction(signedTx.raw) expect(normalizeAddressWith0x(recoveredSigner)).toBe( normalizeAddressWith0x(knownAddress) @@ -198,6 +336,66 @@ describe('Local wallet class', () => { ) }) }) + describe('when using signTransaction with type CIP42', () => { + let celoTransactionBase: CeloTx + let feeCurrency = '0x10c892a6ec43a53e45d0b916b4b7d383b1b78c0f' + let maxFeePerGas = '0x100000000' + let maxPriorityFeePerGas = '0x100000000' + + beforeEach(() => { + celoTransactionBase = { + gas: '1000000000', + from: knownAddress, + to: otherAddress, + chainId: CHAIN_ID, + value: Web3.utils.toWei('1', 'ether'), + nonce: 0, + data: '0xabcdef', + } + }) + + describe('when feeCurrency and maxPriorityFeePerGas and maxFeePerGas are set', () => { + it('signs as a CIP42 tx', async () => { + const transaction: CeloTx = { + ...celoTransactionBase, + feeCurrency, + maxFeePerGas, + maxPriorityFeePerGas, + } + const signedTx: EncodedTransaction = await wallet.signTransaction(transaction) + expect(signedTx.raw).toMatch(/^0x7c/) + }) + }) + describe('when feeCurrency and maxFeePerGas but not maxPriorityFeePerGas are set', () => {}) + + describe('when feeCurrency and maxPriorityFeePerGas but not maxFeePerGas are set', () => {}) + + describe('when gas and one of maxPriorityFeePerGas or maxFeePerGas are set', () => { + it('throws explaining only one kind of gas fee can be set', async () => { + const transaction: CeloTx = { + ...celoTransactionBase, + maxFeePerGas, + maxPriorityFeePerGas, + gasPrice: '0x100000000', + } + expect(async () => await wallet.signTransaction(transaction)).rejects.toThrowError( + 'when "maxFeePerGas" or "maxPriorityFeePerGas" are set, "gasPrice" must not be set' + ) + }) + }) + + describe('when maxPriorityFeePerGas / maxFeePerGas are set but not feeCurrency', () => { + it('signs as a EIP1559 tx', async () => { + const transaction: CeloTx = { + ...celoTransactionBase, + maxFeePerGas, + maxPriorityFeePerGas, + } + const signedTx: EncodedTransaction = await wallet.signTransaction(transaction) + expect(signedTx.raw).toMatch(/^0x02/) + }) + }) + }) describe('when calling signPersonalMessage', () => { test('succeeds', async () => { diff --git a/packages/sdk/wallets/wallet-local/src/signing.test.ts b/packages/sdk/wallets/wallet-local/src/signing.test.ts index ac867abb21..d87ef53304 100644 --- a/packages/sdk/wallets/wallet-local/src/signing.test.ts +++ b/packages/sdk/wallets/wallet-local/src/signing.test.ts @@ -25,123 +25,11 @@ debug(`Account Address 1: ${ACCOUNT_ADDRESS1}`) debug(`Private key 2: ${PRIVATE_KEY2}`) debug(`Account Address 2: ${ACCOUNT_ADDRESS2}`) -async function verifyLocalSigning(web3: Web3, celoTransaction: CeloTx): Promise { - debug('Signer Testing using Account: %s', celoTransaction.from) - const signedTransaction = await web3.eth.signTransaction(celoTransaction) - debug('Singer Testing: Signed transaction %o', signedTransaction) - const rawTransaction: string = signedTransaction.raw - const [signedCeloTransaction, recoveredSigner] = recoverTransaction(rawTransaction) - debug( - 'Transaction was signed by "%s", recovered signer is "%s"', - celoTransaction.from, - recoveredSigner - ) - expect(recoveredSigner.toLowerCase()).toEqual(celoTransaction.from!.toString().toLowerCase()) - - if (celoTransaction.nonce != null) { - debug( - 'Checking nonce actual: %o expected: %o', - signedCeloTransaction.nonce, - parseInt(celoTransaction.nonce.toString(), 16) - ) - expect(signedCeloTransaction.nonce).toEqual(parseInt(celoTransaction.nonce.toString(), 16)) - } - if (celoTransaction.gas != null) { - debug( - 'Checking gas actual %o expected %o', - signedCeloTransaction.gas, - parseInt(celoTransaction.gas.toString(), 16) - ) - expect(signedCeloTransaction.gas).toEqual(parseInt(celoTransaction.gas.toString(), 16)) - } - if (celoTransaction.gasPrice != null) { - debug( - 'Checking gas price actual %o expected %o', - signedCeloTransaction.gasPrice, - parseInt(celoTransaction.gasPrice.toString(), 16) - ) - expect(signedCeloTransaction.gasPrice).toEqual( - parseInt(celoTransaction.gasPrice.toString(), 16) - ) - } - if (celoTransaction.feeCurrency != null) { - debug( - 'Checking fee currency actual %o expected %o', - signedCeloTransaction.feeCurrency, - celoTransaction.feeCurrency - ) - expect(signedCeloTransaction.feeCurrency!.toLowerCase()).toEqual( - celoTransaction.feeCurrency.toLowerCase() - ) - } - if (celoTransaction.gatewayFeeRecipient != null) { - debug( - 'Checking gateway fee recipient actual ' + - `${signedCeloTransaction.gatewayFeeRecipient} expected ${celoTransaction.gatewayFeeRecipient}` - ) - expect(signedCeloTransaction.gatewayFeeRecipient!.toLowerCase()).toEqual( - celoTransaction.gatewayFeeRecipient.toLowerCase() - ) - } - if (celoTransaction.gatewayFee != null) { - debug( - 'Checking gateway fee value actual %o expected %o', - signedCeloTransaction.gatewayFee, - celoTransaction.gatewayFee.toString() - ) - expect(signedCeloTransaction.gatewayFee).toEqual(celoTransaction.gatewayFee.toString()) - } - if (celoTransaction.data != null) { - debug(`Checking data actual ${signedCeloTransaction.data} expected ${celoTransaction.data}`) - expect(signedCeloTransaction.data!.toLowerCase()).toEqual(celoTransaction.data.toLowerCase()) - } -} - -async function verifyLocalSigningInAllPermutations( - web3: Web3, - from: string, - to: string -): Promise { - const amountInWei: string = Web3.utils.toWei('1', 'ether') - const nonce = 0 - const badNonce = 100 - const gas = 10 - const gasPrice = 99 - const feeCurrency = ACCOUNT_ADDRESS1 - const gatewayFeeRecipient = ACCOUNT_ADDRESS2 - const gatewayFee = '0x5678' - const data = '0xabcdef' - const chainId = 1 - - // tslint:disable:no-bitwise - // Test all possible combinations for rigor. - for (let i = 0; i < 16; i++) { - const celoTransaction: CeloTx = { - from, - to, - value: amountInWei, - nonce, - gasPrice, - chainId, - gas, - feeCurrency: i & 1 ? feeCurrency : undefined, - gatewayFeeRecipient: i & 2 ? gatewayFeeRecipient : undefined, - gatewayFee: i & 4 ? gatewayFee : undefined, - data: i & 8 ? data : undefined, - } - await verifyLocalSigning(web3, celoTransaction) - } - // tslint:enable:no-bitwise - - // A special case. - // An incorrect nonce will only work, if no implict calls to estimate gas are required. - await verifyLocalSigning(web3, { from, to, nonce: badNonce, gas, gasPrice, chainId }) -} - // These tests verify the signTransaction WITHOUT the ParamsPopulator describe('Transaction Utils', () => { // only needed for the eth_coinbase rcp call let connection: Connection + let web3: Web3 const mockProvider: Provider = { send: (payload: JsonRpcPayload, callback: Callback): void => { if (payload.method === 'eth_coinbase') { @@ -151,44 +39,231 @@ describe('Transaction Utils', () => { result: '0xc94770007dda54cF92009BFF0dE90c06F603a09f', } callback(null, response) + } else if (payload.method === 'eth_gasPrice') { + const response: JsonRpcResponse = { + jsonrpc: payload.jsonrpc, + id: Number(payload.id), + result: '0x09184e72a000', + } + callback(null, response) } else { callback(new Error(payload.method)) } }, } - const web3: Web3 = new Web3() - beforeEach(() => { + const setupConnection = async () => { + web3 = new Web3() web3.setProvider(mockProvider as any) connection = new Connection(web3) connection.wallet = new LocalWallet() - }) + } + async function verifyLocalSigning(celoTransaction: CeloTx): Promise { + let recoveredSigner: string + let signedCeloTransaction: CeloTx + beforeAll(async () => { + const signedTransaction = await web3.eth.signTransaction(celoTransaction) + const recovery = recoverTransaction(signedTransaction.raw) + signedCeloTransaction = recovery[0] + recoveredSigner = recovery[1] + }) - afterEach(() => { - connection.stop() - }) + test('Signer matches recovered signer', () => { + expect(recoveredSigner.toLowerCase()).toEqual(celoTransaction.from!.toString().toLowerCase()) + }) + + test('Checking nonce', () => { + if (celoTransaction.nonce != null) { + expect(signedCeloTransaction.nonce).toEqual(parseInt(celoTransaction.nonce.toString(), 16)) + } + }) + + test('Checking gas', () => { + if (celoTransaction.gas != null) { + expect(signedCeloTransaction.gas).toEqual(parseInt(celoTransaction.gas.toString(), 16)) + } + }) + test('Checking gas price', () => { + if (celoTransaction.gasPrice != null) { + expect(signedCeloTransaction.gasPrice).toEqual( + parseInt(celoTransaction.gasPrice.toString(), 16) + ) + } + }) + test('Checking maxFeePerGas', () => { + if (celoTransaction.maxFeePerGas != null) { + expect(signedCeloTransaction.maxFeePerGas).toEqual( + parseInt(celoTransaction.maxFeePerGas.toString(), 16) + ) + } + }) + test('Checking maxPriorityFeePerGas', () => { + if (celoTransaction.maxPriorityFeePerGas != null) { + debug( + 'Checking gas price', + signedCeloTransaction.maxPriorityFeePerGas, + parseInt(celoTransaction.maxPriorityFeePerGas.toString(), 16) + ) + expect(signedCeloTransaction.maxPriorityFeePerGas).toEqual( + parseInt(celoTransaction.maxPriorityFeePerGas.toString(), 16) + ) + } + }) + test('Checking feeCurrency', () => { + if (celoTransaction.feeCurrency != null) { + debug( + 'Checking fee currency', + signedCeloTransaction.feeCurrency, + celoTransaction.feeCurrency + ) + expect(signedCeloTransaction.feeCurrency!.toLowerCase()).toEqual( + celoTransaction.feeCurrency.toLowerCase() + ) + } + }) + test('gatewayFeeRecipient', () => { + if (celoTransaction.gatewayFeeRecipient != null) { + debug( + 'Checking gateway fee recipient actual ' + + `${signedCeloTransaction.gatewayFeeRecipient} expected ${celoTransaction.gatewayFeeRecipient}` + ) + expect(signedCeloTransaction.gatewayFeeRecipient!.toLowerCase()).toEqual( + celoTransaction.gatewayFeeRecipient.toLowerCase() + ) + } + }) + test('Checking gateway fee value', () => { + if (celoTransaction.gatewayFee != null) { + debug( + 'Checking gateway fee value', + signedCeloTransaction.gatewayFee, + celoTransaction.gatewayFee.toString() + ) + expect(signedCeloTransaction.gatewayFee).toEqual(celoTransaction.gatewayFee.toString()) + } + }) + test('Checking data', () => { + if (celoTransaction.data != null) { + debug(`Checking data actual ${signedCeloTransaction.data} expected ${celoTransaction.data}`) + expect(signedCeloTransaction.data!.toLowerCase()).toEqual( + celoTransaction.data.toLowerCase() + ) + } + }) + } + + async function verifyLocalSigningInAllPermutations(from: string, to: string): Promise { + const amountInWei: string = Web3.utils.toWei('1', 'ether') + const nonce = 0 + const badNonce = 100 + const gas = 10 + const gasPrice = 99 + const feeCurrency = ACCOUNT_ADDRESS1 + const gatewayFeeRecipient = ACCOUNT_ADDRESS2 + const gatewayFee = '0x5678' + const data = '0xabcdef' + const chainId = 1 + + // tslint:disable:no-bitwise + // Test all possible combinations for rigor. + for (let i = 0; i < 16; i++) { + const celoTransaction: CeloTx = { + from, + to, + value: amountInWei, + nonce, + gasPrice: i % 2 === 0 ? gasPrice : undefined, + maxFeePerGas: i % 2 === 1 ? gasPrice : undefined, + maxPriorityFeePerGas: i % 2 === 1 ? gasPrice : undefined, + chainId, + gas, + feeCurrency: i % 3 === 0 ? feeCurrency : undefined, + gatewayFeeRecipient: i % 7 === 0 ? gatewayFeeRecipient : undefined, + gatewayFee: i % 7 === 0 ? gatewayFee : undefined, + data: i & 8 ? data : undefined, + } + describe(transactionDescription(celoTransaction), () => { + verifyLocalSigning(celoTransaction) + }) + } + + function transactionDescription(celoTransaction: CeloTx) { + const description: string[] = [] + if (celoTransaction.gasPrice != undefined) { + description.push('Testing Legacy with') + } else if ( + (celoTransaction.maxFeePerGas != undefined && celoTransaction.feeCurrency != undefined) || + celoTransaction.gatewayFeeRecipient !== undefined + ) { + description.push('Testing CIP42 with') + } else { + console.warn( + 'FEE DATA', + celoTransaction.maxFeePerGas, + celoTransaction.maxPriorityFeePerGas, + celoTransaction.gasPrice + ) + description.push('Testing EIP1559 with') + } + if (celoTransaction.data != undefined) { + description.push(`data: ${celoTransaction.data}`) + } + + if (celoTransaction.feeCurrency != undefined) { + description.push(`fee currency: ${celoTransaction.feeCurrency}`) + } + + if (celoTransaction.gatewayFeeRecipient != undefined) { + description.push(`gateway fee recipient: ${celoTransaction.gatewayFeeRecipient}`) + } + if (celoTransaction.gatewayFee != undefined) { + description.push(`gateway fee: ${celoTransaction.gatewayFee}`) + } + + return description.join(' ') + } + // tslint:enable:no-bitwise + + // A special case. + // An incorrect nonce will only work, if no implict calls to estimate gas are required. + describe('Testing with bad nonce', () => { + verifyLocalSigning({ from, to, nonce: badNonce, gas, gasPrice, chainId }) + }) + } describe('Signer Testing with single local account and pay gas in CELO', () => { - it('Test1 should be able to sign and get the signer back with single local account', async () => { - jest.setTimeout(60 * 1000) - connection.addAccount(PRIVATE_KEY1) - await verifyLocalSigningInAllPermutations(web3, ACCOUNT_ADDRESS1, ACCOUNT_ADDRESS2) + describe('Test1 should be able to sign and get the signer back with single local account', () => { + beforeAll(async () => { + await setupConnection() + // jest.setTimeout(60 * 1000) + connection.addAccount(PRIVATE_KEY1) + }) + verifyLocalSigningInAllPermutations(ACCOUNT_ADDRESS1, ACCOUNT_ADDRESS2) + afterAll(() => connection.stop()) }) }) describe('Signer Testing with multiple local accounts', () => { - it('Test2 should be able to sign with first account and get the signer back with multiple local accounts', async () => { - jest.setTimeout(60 * 1000) - connection.addAccount(PRIVATE_KEY1) - connection.addAccount(PRIVATE_KEY2) - await verifyLocalSigningInAllPermutations(web3, ACCOUNT_ADDRESS1, ACCOUNT_ADDRESS2) + describe('Test2 should be able to sign with first account and get the signer back with multiple local accounts', () => { + beforeAll(async () => { + await setupConnection() + // jest.setTimeout(60 * 1000) + connection.addAccount(PRIVATE_KEY1) + connection.addAccount(PRIVATE_KEY2) + }) + verifyLocalSigningInAllPermutations(ACCOUNT_ADDRESS1, ACCOUNT_ADDRESS2) + afterAll(() => connection.stop()) }) - it('Test3 should be able to sign with second account and get the signer back with multiple local accounts', async () => { - jest.setTimeout(60 * 1000) - connection.addAccount(PRIVATE_KEY1) - connection.addAccount(PRIVATE_KEY2) - await verifyLocalSigningInAllPermutations(web3, ACCOUNT_ADDRESS2, ACCOUNT_ADDRESS1) + describe('Test3 should be able to sign with second account and get the signer back with multiple local accounts', () => { + beforeAll(async () => { + await setupConnection() + // jest.setTimeout(60 * 1000) + connection.addAccount(PRIVATE_KEY1) + connection.addAccount(PRIVATE_KEY2) + }) + verifyLocalSigningInAllPermutations(ACCOUNT_ADDRESS2, ACCOUNT_ADDRESS1) + afterAll(() => connection.stop()) }) }) }) diff --git a/packages/sdk/wallets/wallet-local/tsconfig.json b/packages/sdk/wallets/wallet-local/tsconfig.json index e267672350..b37d430f5c 100644 --- a/packages/sdk/wallets/wallet-local/tsconfig.json +++ b/packages/sdk/wallets/wallet-local/tsconfig.json @@ -5,5 +5,6 @@ "outDir": "lib" }, "include": ["src"], + "exclude": ["**/*.test.ts"], "references": [{ "path": "../../utils" }] } diff --git a/packages/sdk/wallets/wallet-rpc/src/rpc-signer.ts b/packages/sdk/wallets/wallet-rpc/src/rpc-signer.ts index 2e6cca39f0..00fce4de19 100644 --- a/packages/sdk/wallets/wallet-rpc/src/rpc-signer.ts +++ b/packages/sdk/wallets/wallet-rpc/src/rpc-signer.ts @@ -82,14 +82,21 @@ export class RpcSigner implements Signer { throw new Error(`RpcSigner cannot sign tx with 'from' ${tx.from}`) } // see geth SendTxArgs type - // https://github.com/celo-org/celo-blockchain/blob/bf2ba25426f9956384220b8b2ce302337e7fa8a4/internal/ethapi/api.go#L1363 + // https://github.com/celo-org/celo-blockchain/blob/fc20d6921478cda68fc88797078f20053bae8866/internal/ethapi/api.go#L1241C6-L1241C20 const rpcTx = { ...tx, nonce: toRpcHex(tx.nonce), value: toRpcHex(tx.value), gas: toRpcHex(tx.gas), - gasPrice: toRpcHex(tx.gasPrice), gatewayFee: toRpcHex(tx.gatewayFee), + ...(tx.gasPrice + ? { + gasPrice: toRpcHex(tx.gasPrice), + } + : { + maxPriorityFeePerGas: toRpcHex(tx.maxPriorityFeePerGas), + maxFeePerGas: toRpcHex(tx.maxFeePerGas), + }), } return this.callAndCheckResponse(RpcSignerEndpoint.SignTransaction, [rpcTx]) } diff --git a/packages/sdk/wallets/wallet-rpc/src/rpc-wallet.test.ts b/packages/sdk/wallets/wallet-rpc/src/rpc-wallet.test.ts index cb23c4942e..8f25fe84d2 100644 --- a/packages/sdk/wallets/wallet-rpc/src/rpc-wallet.test.ts +++ b/packages/sdk/wallets/wallet-rpc/src/rpc-wallet.test.ts @@ -178,8 +178,26 @@ testWithGanache('rpc-wallet', (web3) => { } }) - test('succeeds', async () => { - await expect(rpcWallet.signTransaction(celoTransaction)).resolves.not.toBeUndefined() + test('succeeds with old school pricing', async () => { + await expect( + rpcWallet.signTransaction(celoTransaction) + ).resolves.toMatchInlineSnapshot( + `"0xf86b8081991094588e4b68193001e4d10928660ab4165b813717c08a0100000000000000000083abcdef25a073bb7eaa60c810af1fad0f68fa15d4714f9990d0202b62797f6134493ec9f6fba046c13e92017228c2c8f0fae74ddd735021817f2f9757cd66debed078daf4070e"` + ) + }) + + test('succeeds with with FeeMarketFields', async () => { + const feeMarketTransaction = { + ...celoTransaction, + gasPrice: undefined, + maxFeePerGas: '1500000000', + maxPriorityFeePerGas: '1500000000', + } + await expect( + rpcWallet.signTransaction(feeMarketTransaction) + ).resolves.toMatchInlineSnapshot( + `"0xf86a80801094588e4b68193001e4d10928660ab4165b813717c08a0100000000000000000083abcdef26a05e9c1e7690d05f3e1433c824fbd948643ff6c618e347ea8c23a6363f3b17cdffa072dc1c22d6147be7b4b7b3cf51eb73b8bedd7940d7b668dcd7ef688a2354a631"` + ) }) // TODO(yorke): enable once fixed: https://github.com/celo-org/celo-monorepo/issues/4077 diff --git a/yarn.lock b/yarn.lock index d18e8fa1d4..f9d862be2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -267,6 +267,11 @@ ethers "~4.0.4" lodash "^4.17.21" +"@adraffy/ens-normalize@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.9.0.tgz#223572538f6bea336750039bb43a4016dcc8182d" + integrity sha512-iowxq3U30sghZotgl4s/oJRci6WPBfNO5YYgk2cIOMCHr3LeGPcsZjCEr+33Q4N+oV3OABDAtA+pyvWjbvBifQ== + "@ampproject/remapping@^2.2.0": version "2.2.1" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" @@ -3285,11 +3290,28 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@noble/curves@1.0.0", "@noble/curves@~1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.0.0.tgz#e40be8c7daf088aaf291887cbc73f43464a92932" + integrity sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw== + dependencies: + "@noble/hashes" "1.3.0" + "@noble/hashes@1.2.0", "@noble/hashes@~1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.2.0.tgz#a3150eeb09cc7ab207ebf6d7b9ad311a9bdbed12" integrity sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ== +"@noble/hashes@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.0.tgz#085fd70f6d7d9d109671090ccae1d3bec62554a1" + integrity sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg== + +"@noble/hashes@~1.3.0": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" + integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== + "@noble/secp256k1@1.7.1", "@noble/secp256k1@~1.7.0": version "1.7.1" resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c" @@ -4097,6 +4119,15 @@ "@noble/secp256k1" "~1.7.0" "@scure/base" "~1.1.0" +"@scure/bip32@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.0.tgz#6c8d980ef3f290987736acd0ee2e0f0d50068d87" + integrity sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q== + dependencies: + "@noble/curves" "~1.0.0" + "@noble/hashes" "~1.3.0" + "@scure/base" "~1.1.0" + "@scure/bip39@1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.1.1.tgz#b54557b2e86214319405db819c4b6a370cf340c5" @@ -4105,6 +4136,14 @@ "@noble/hashes" "~1.2.0" "@scure/base" "~1.1.0" +"@scure/bip39@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.0.tgz#a207e2ef96de354de7d0002292ba1503538fc77b" + integrity sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg== + dependencies: + "@noble/hashes" "~1.3.0" + "@scure/base" "~1.1.0" + "@sinclair/typebox@^0.25.16": version "0.25.24" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" @@ -5678,6 +5717,13 @@ dependencies: web3 "*" +"@types/ws@^8.5.4": + version "8.5.5" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.5.tgz#af587964aa06682702ee6dcbc7be41a80e4b28eb" + integrity sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" @@ -5702,6 +5748,11 @@ dependencies: "@types/yargs-parser" "*" +"@wagmi/chains@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@wagmi/chains/-/chains-1.6.0.tgz#eb992ad28dbaaab729b5bcab3e5b461e8a035656" + integrity sha512-5FRlVxse5P4ZaHG3GTvxwVANSmYJas1eQrTBHhjxVtqXoorm0aLmCHbhmN8Xo1yu09PaWKlleEvfE98yH4AgIw== + "@xmldom/xmldom@^0.8.3": version "0.8.7" resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.7.tgz#8b1e39c547013941974d83ad5e9cf5042071a9a0" @@ -5761,6 +5812,11 @@ abi-to-sol@^0.6.6: prettier "^2.7.1" prettier-plugin-solidity "^1.0.0-dev.23" +abitype@0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/abitype/-/abitype-0.9.3.tgz#294d25288ee683d72baf4e1fed757034e3c8c277" + integrity sha512-dz4qCQLurx97FQhnb/EIYTk/ldQ+oafEDUqC0VVIeQS1Q48/YWt/9YNfMmp9SLFqN41ktxny3c8aYxHjmFIB/w== + abort-controller@3.0.0, abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -14238,6 +14294,11 @@ isomorphic-fetch@^3.0.0: node-fetch "^2.6.1" whatwg-fetch "^3.4.1" +isomorphic-ws@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz#e5529148912ecb9b451b46ed44d53dae1ce04bbf" + integrity sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw== + isomorphic-ws@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" @@ -23292,6 +23353,22 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" +viem@~1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/viem/-/viem-1.5.4.tgz#4ba43cda4be5ec193d9f1c092955743705a37450" + integrity sha512-/B2KbAiTqiPd6fzJgz4pgS879IXbHfO44RP/0nsRvBEuFJvHQlekNIAHTa4d3LPlsHWAM8GcH4m2P5ZvtEHVxA== + dependencies: + "@adraffy/ens-normalize" "1.9.0" + "@noble/curves" "1.0.0" + "@noble/hashes" "1.3.0" + "@scure/bip32" "1.3.0" + "@scure/bip39" "1.2.0" + "@types/ws" "^8.5.4" + "@wagmi/chains" "1.6.0" + abitype "0.9.3" + isomorphic-ws "5.0.0" + ws "8.12.0" + vm2@^3.9.11: version "3.9.19" resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.19.tgz#be1e1d7a106122c6c492b4d51c2e8b93d3ed6a4a" @@ -25109,6 +25186,11 @@ ws@7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@8.12.0: + version "8.12.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.12.0.tgz#485074cc392689da78e1828a9ff23585e06cddd8" + integrity sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig== + ws@8.2.3: version "8.2.3" resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba"