diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..4eca51f39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/** +output/** +misc/** diff --git a/.npmignore b/.npmignore new file mode 100644 index 000000000..5e2d36c60 --- /dev/null +++ b/.npmignore @@ -0,0 +1,26 @@ + +# Ignore TypeScript config and caches +tsconfig.*.json +tsconfig.tsbuildinfo +rollup.config.js +output/** + +# Ignore admin scripts and files +src.ts/_admin/** +lib.commonjs/_admin/** +lib.esm/_admin/** +types/_admin/** +reporter.cjs +package-commonjs.json + +# Ignore test cases +src.ts/_tests/** +lib.commonjs/_tests/** +lib.esm/_tests/** +types/_tests/** +testcases/** + +# Ignore random junk +.DS_Store +node_modules/** +misc/** diff --git a/package-commonjs.json b/package-commonjs.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/package-commonjs.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..535ebb2d5 --- /dev/null +++ b/package.json @@ -0,0 +1,131 @@ +{ + "author": "Richard Moore ", + "dependencies": { + "@noble/hashes": "1.1.2", + "@noble/secp256k1": "1.6.3", + "aes-js": "4.0.0-beta.2", + "tslib": "2.4.0", + "ws": "8.5.0" + }, + "description": "Ethereum library.", + "devDependencies": { + "@rollup/plugin-node-resolve": "13.3.0", + "@types/mocha": "9.1.1", + "c8": "7.12.0", + "mocha": "10.0.0", + "rollup": "2.78.1", + "typescript": "4.7.4", + "uglify-js": "3.17.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "browser": { + "./lib.esm/crypto/crypto.js": "./lib.esm/crypto/crypto-browser.js", + "./lib.esm/providers/provider-ipcsocket.js": "./lib.esm/providers/provider-ipcsocket-browser.js", + "./lib.esm/providers/ws.js": "./lib.esm/providers/ws-browser.js", + "./lib.esm/utils/base64.js": "./lib.esm/utils/base64-browser.js", + "./lib.esm/utils/geturl.js": "./lib.esm/utils/geturl-browser.js", + "./lib.esm/wordlists/wordlists.js": "./lib.esm/wordlists/wordlists-browser.js" + }, + "exports": { + ".": { + "import": "./lib.esm/index.js", + "require": "./lib.commonjs/index.js", + "types": "./types/index.d.ts" + }, + "./abi": { + "import": "./lib.esm/abi/index.js", + "require": "./lib.commonjs/abi/index.js", + "types": "./types/abi/index.d.ts" + }, + "./address": { + "import": "./lib.esm/address/index.js", + "require": "./lib.commonjs/address/index.js", + "types": "./types/address/index.d.ts" + }, + "./constants": { + "import": "./lib.esm/constants/index.js", + "require": "./lib.commonjs/constants/index.js", + "types": "./types/constants/index.d.ts" + }, + "./contract": { + "import": "./lib.esm/contract/index.js", + "require": "./lib.commonjs/contract/index.js", + "types": "./types/contract/index.d.ts" + }, + "./crypto": { + "import": "./lib.esm/crypto/index.js", + "require": "./lib.commonjs/crypto/index.js", + "types": "./types/crypto/index.d.ts" + }, + "./hash": { + "import": "./lib.esm/hash/index.js", + "require": "./lib.commonjs/hash/index.js", + "types": "./types/hash/index.d.ts" + }, + "./providers": { + "import": "./lib.esm/providers/index.js", + "require": "./lib.commonjs/providers/index.js", + "types": "./types/providers/index.d.ts" + }, + "./transaction": { + "import": "./lib.esm/transaction/index.js", + "require": "./lib.commonjs/transaction/index.js", + "types": "./types/transaction/index.d.ts" + }, + "./utils": { + "import": "./lib.esm/utils/index.js", + "require": "./lib.commonjs/utils/index.js", + "types": "./types/utils/index.d.ts" + }, + "./wallet": { + "import": "./lib.esm/wallet/index.js", + "require": "./lib.commonjs/wallet/index.js", + "types": "./types/wallet/index.d.ts" + }, + "./wordlists": { + "import": "./lib.esm/wordlists/index.js", + "require": "./lib.commonjs/wordlists/index.js", + "types": "./types/wordlistsindex.d.ts" + } + }, + "keywords": [ + "ethereum", + "ethers", + "ethersjs" + ], + "license": "MIT", + "main": "./lib.commonjs/index.js", + "module": "./lib.esm/index.js", + "name": "ethers", + "publishConfig": { + "access": "public", + "tag": "beta-exports" + }, + "repository": { + "type": "git", + "url": "git://github.com/ethers-io/ethers.js.git" + }, + "scripts": { + "clean": "rm -rf dist lib.esm lib.commonjs types", + "build": "tsc --project tsconfig.esm.json", + "build-commonjs": "tsc --project tsconfig.commonjs.json && cp ./package-commonjs.json ./lib.commonjs/package.json", + "build-types": "tsc --project tsconfig.types.json", + "build-docs": "echo 'foo'", + "auto-build": "npm run build -- -w", + "build-all": "npm run build && npm run build-commonjs && npm run build-types", + "build-clean": "npm run clean && npm run build-all", + "_dist-stats": "gzip -k9f -S '.gz' ./dist/ethers.min.js && gzip -k9f -S '.gz' ./dist/wordlists-extra.min.js && du -hs ./dist/*.gz && echo '' && du -hs ./dist/*.js", + "_build-dist": "rollup -c && uglifyjs ./dist/ethers.js -o ./dist/ethers.min.js && uglifyjs ./dist/wordlists-extra.js -o ./dist/wordlists-extra.min.js && npm run _dist-stats", + "build-dist": "npm run build && npm run _build-dist", + "stats": "echo 'Dependencies' && npm ls --all --omit=dev", + "test": "mocha --reporter ./reporter.cjs ./lib.esm/_tests/test-*.js", + "test-commonjs": "mocha --reporter ./reporter.cjs ./lib.commonjs/_tests/test-*.js", + "test-coverage": "c8 -o output -r lcov -r text mocha --no-color --reporter ./reporter.cjs ./lib.esm/_tests/test-*.js | tee output/summary.txt" + }, + "sideEffects": false, + "type": "module", + "types": "./types/index.d.ts", + "version": "6.0.0-beta-exports.0" +} diff --git a/reporter.cjs b/reporter.cjs new file mode 100644 index 000000000..14d579ab8 --- /dev/null +++ b/reporter.cjs @@ -0,0 +1,213 @@ +'use strict'; + +const Mocha = require('mocha'); +const { + EVENT_RUN_BEGIN, + EVENT_RUN_END, + EVENT_TEST_BEGIN, + EVENT_TEST_END, + EVENT_TEST_FAIL, + EVENT_TEST_PASS, + EVENT_SUITE_BEGIN, + EVENT_SUITE_END +} = Mocha.Runner.constants; + + +// See: https://stackoverflow.com/questions/9781218/how-to-change-node-jss-console-font-color +let disableColor = false; //!(process.stdout.isTTY); +process.argv.forEach((arg) => { + if (arg === "--no-color") { disableColor = true; } +}); + +const Colors = { + "blue": "\x1b[0;34m", + "blue+": "\x1b[0;1;34m", + "cyan": "\x1b[0;36m", + "cyan+": "\x1b[0;1;36m", + "green": "\x1b[0;32m", + "green+": "\x1b[0;1;32m", + "magenta-": "\x1b[0;2;35m", + "magenta": "\x1b[0;35m", + "magenta+": "\x1b[0;1;35m", + "red": "\x1b[0;31m", + "red+": "\x1b[0;1;31m", + "yellow": "\x1b[0;33m", + "yellow+": "\x1b[0;1;33m", + "dim": "\x1b[0;2;37m", + "bold": "\x1b[0;1;37m", + "normal": "\x1b[0m" +}; + +function colorify(text) { + return unescapeColor(text.replace(/(<([a-z+]+)>)/g, (all, _, color) => { + if (disableColor) { return ""; } + + const seq = Colors[color]; + if (seq == null) { + console.log("UNKNOWN COLOR:", color); + return ""; + } + return seq; + })) + Colors.normal; +} + +function escapeColor(text) { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +function unescapeColor(text) { + return text.replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&"); +} + +function getString(value) { + if (value instanceof Error) { + return value.stack; + } + return String(value); +} + +// To prevent environments from thinking we're dead due to lack of +// output, we force output after 20s +function getTime() { return (new Date()).getTime(); } +const KEEP_ALIVE = 20 * 1000; + +// this reporter outputs test results, indenting two spaces per suite +class MyReporter { + constructor(runner) { + this._errors = [ ]; + this._indents = 1; + this._lastLog = getTime(); + this._lastPass = ""; + this._lastPrefix = null; + this._lastPrefixHeader = null; + this._testLogs = [ ]; + this._suiteLogs = [ ]; + this._prefixCount = 0; + const stats = runner.stats; + + runner.once(EVENT_RUN_BEGIN, () => { + + }).on(EVENT_SUITE_BEGIN, (suite) => { + this._suiteLogs.push([ ]); + suite._ethersLog = (text) => { + this._suiteLogs[this._suiteLogs.length - 1].push(getString(text)) + }; + if (suite.title.trim()) { + this.log(`Suite: ${ escapeColor(suite.title) }`) + } + this.increaseIndent(); + + }).on(EVENT_SUITE_END, (suite) => { + this.flush(true); + this.decreaseIndent(); + const logs = this._suiteLogs.pop(); + if (logs.length) { + logs.join("\n").split("\n").forEach((line) => { + this.log(` >> ${ escapeColor(line) }`); + }); + } + if (suite.title.trim()) { this.log(""); } + + }).on(EVENT_TEST_BEGIN, (test) => { + this._testLogs.push([ ]); + test._ethersLog = (text) => { + this._testLogs[this._testLogs.length - 1].push(getString(text)) + }; + + }).on(EVENT_TEST_END, (test) => { + const logs = this._testLogs.pop(); + if (logs.length) { + this.flush(false); + logs.join("\n").split("\n").forEach((line) => { + this.log(` >> ${ escapeColor(line) }`); + }); + } + + }).on(EVENT_TEST_PASS, (test) => { + this.addPass(test.title); + + }).on(EVENT_TEST_FAIL, (test, error) => { + this.flush(); + this._errors.push({ test, error }); + this.log( + ` [ fail(${ this._errors.length }): ${ escapeColor(test.title) } - ${ escapeColor(error.message) } ]` + ); + + }).once(EVENT_RUN_END, () => { + this.flush(true); + this.indent = 0; + + if (this._errors.length) { + this._errors.forEach(({ test, error }, index) => { + this.log("---------------------"); + this.log(`ERROR ${ index + 1 }: ${ escapeColor(test.title) }`); + this.log(escapeColor(error.toString())); + }); + this.log("====================="); + } + + const { duration, passes, failures } = stats; + const total = passes + failures; + this.log(`Done: ${ passes }/${ total } passed (${ failures } failed)`); + }); + } + + log(line) { + this._lastLog = getTime(); + const indent = Array(this._indents).join(' '); + console.log(`${ indent }${ colorify(line) }`); + } + + addPass(line) { + const prefix = line.split(":")[0]; + if (prefix === this._lastPrefix) { + this._prefixCount++; + if (getTime() - this._lastLog > KEEP_ALIVE) { + const didLog = this.flush(false); + // Nothing was output, so show *something* so the + // environment knows we're still alive and kicking + if (!didLog) { + this.log(" [ keep-alive; forced output ]") + } + } + } else { + this.flush(true); + this._lastPrefixHeader = null; + this._lastPrefix = prefix; + this._prefixCount = 1; + } + this._lastLine = line; + } + + flush(reset) { + let didLog = false; + if (this._lastPrefix != null) { + if (this._prefixCount === 1 && this._lastPrefixHeader == null) { + this.log(escapeColor(this._lastLine)); + didLog = true; + } else if (this._prefixCount > 0) { + if (this._lastPrefixHeader !== this._lastPrefix) { + this.log(`${ escapeColor(this._lastPrefix) }:`); + this._lastPrefixHeader = this._lastPrefix; + } + this.log(` - ${ this._prefixCount } tests passed (prefix coalesced)`); + didLog = true; + } + } + + if (reset) { + this._lastPrefixHeader = null; + this._lastPrefix = null; + } + + this._prefixCount = 0; + + return didLog; + } + + increaseIndent() { this._indents++; } + + decreaseIndent() { this._indents--; } +} + +module.exports = MyReporter; diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 000000000..e71e63ed1 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,47 @@ + +import { nodeResolve } from '@rollup/plugin-node-resolve'; + +function getConfig(opts) { + if (opts == null) { opts = { }; } + + const file = `./dist/ethers${ (opts.suffix || "") }.js`; + const exportConditions = [ "default", "module", "import" ]; + const mainFields = [ "module", "main" ]; + if (opts.browser) { mainFields.unshift("browser"); } + + return { + input: "./lib.esm/index.js", + output: { + file, + format: "esm", + sourcemap: true + }, + treeshake: false, + plugins: [ nodeResolve({ + exportConditions, + mainFields, + modulesOnly: true, + preferBuiltins: false + }) ], +// external: [ "crypto" ] + }; +} + +export default [ + getConfig({ browser: true }), + { + input: "./lib.esm/wordlists/wordlists-extra.js", + output: { + file: "./dist/wordlists-extra.js", + format: "esm", + sourcemap: true + }, + treeshake: true, + plugins: [ nodeResolve({ + exportConditions: [ "default", "module", "import" ], + mainFields: [ "browser", "module", "main" ], + modulesOnly: true, + preferBuiltins: false + }) ], + } +]; diff --git a/src.ts/_admin/update-version-const.ts b/src.ts/_admin/update-version-const.ts new file mode 100644 index 000000000..3b1f73350 --- /dev/null +++ b/src.ts/_admin/update-version-const.ts @@ -0,0 +1,8 @@ +import { atomicWrite } from "./utils/fs.js"; +import { resolve } from "./utils/path.js"; +import { loadJson } from "./utils/json.js"; + +const version = loadJson(resolve("package.json")).version; + +const content = `export const version = "${ version }";\n`; +atomicWrite(resolve("src.ts/_version.ts"), content); diff --git a/src.ts/_admin/utils/fs.ts b/src.ts/_admin/utils/fs.ts new file mode 100644 index 000000000..692ba12f6 --- /dev/null +++ b/src.ts/_admin/utils/fs.ts @@ -0,0 +1,9 @@ +import fs from "fs"; + +import { resolve } from "./path.js"; + +export function atomicWrite(path: string, value: string | Uint8Array): void { + const tmp = resolve(".atomic-tmp"); + fs.writeFileSync(tmp, value); + fs.renameSync(tmp, path); +} diff --git a/src.ts/_admin/utils/json.ts b/src.ts/_admin/utils/json.ts new file mode 100644 index 000000000..a21251afd --- /dev/null +++ b/src.ts/_admin/utils/json.ts @@ -0,0 +1,32 @@ +import fs from "fs"; + +import { atomicWrite } from "./fs.js"; + + +export function loadJson(path: string): any { + return JSON.parse(fs.readFileSync(path).toString()); +} + +type Replacer = (key: string, value: any) => any; + +export function saveJson(filename: string, data: any, sort?: boolean): any { + + let replacer: (Replacer | undefined) = undefined; + if (sort) { + replacer = (key, value) => { + if (Array.isArray(value)) { + // pass + } else if (value && typeof(value) === "object") { + const keys = Object.keys(value); + keys.sort(); + return keys.reduce((accum, key) => { + accum[key] = value[key]; + return accum; + }, >{}); + } + return value; + }; + } + + atomicWrite(filename, JSON.stringify(data, replacer, 2) + "\n"); +} diff --git a/src.ts/_admin/utils/path.ts b/src.ts/_admin/utils/path.ts new file mode 100644 index 000000000..b81eeba8c --- /dev/null +++ b/src.ts/_admin/utils/path.ts @@ -0,0 +1,14 @@ +import { dirname, resolve as _resolve } from "path"; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export const ROOT = _resolve(__dirname, "../../../"); +console.log(ROOT); + +export function resolve(...args: Array): string { + args = args.slice(); + args.unshift(ROOT); + return _resolve.apply(null, args); +} diff --git a/src.ts/_tests/test-abi.ts b/src.ts/_tests/test-abi.ts new file mode 100644 index 000000000..f92bc8f80 --- /dev/null +++ b/src.ts/_tests/test-abi.ts @@ -0,0 +1,43 @@ +import assert from "assert"; +import { loadTests } from "./utils.js"; + +import { TestCaseAbi, TestCaseAbiVerbose } from "./types.js"; + +import { defaultAbiCoder } from "../index.js"; + +function equal(actual: any, expected: TestCaseAbiVerbose): void { + switch (expected.type) { + case "address": case "boolean": case "hexstring": case "string": + assert.equal(actual, expected.value); + return; + case "number": + assert.equal(actual, BigInt(expected.value)); + return + case "array": case "object": + assert.ok(Array.isArray(actual), "!array"); + assert.equal(actual.length, expected.value.length, ".length mismatch"); + for (let i = 0; i < actual.length; i++) { + equal(actual[i], expected.value[i]); + } + return; + } + throw new Error(`unsupported: ${ expected }`); +} + +describe("Tests ABI Coder", function() { + const tests = loadTests("abi"); + + for (const test of tests) { + it(`tests ABI encoding: (${ test.name })`, function() { + const encoded = defaultAbiCoder.encode([ test.type ], [ test.value ]); + assert.equal(encoded, test.encoded, "encoded"); + }); + } + + for (const test of tests) { + it(`tests ABI decoding: (${ test.name })`, function() { + const decoded = defaultAbiCoder.decode([ test.type ], test.encoded)[0]; + equal(decoded, test.verbose); + }); + } +}); diff --git a/src.ts/_tests/test-address.ts b/src.ts/_tests/test-address.ts new file mode 100644 index 000000000..373d91544 --- /dev/null +++ b/src.ts/_tests/test-address.ts @@ -0,0 +1,132 @@ +import assert from "assert"; + +import { loadTests } from "./utils.js"; + +import type { + TestCaseAccount, + TestCaseCreate, + TestCaseCreate2 +} from "./types.js"; + +import { + getAddress, getIcapAddress, + getCreateAddress, getCreate2Address +} from "../index.js"; + + +describe("computes checksum address", function() { + const tests = loadTests("accounts"); + for (const test of tests) { + it(`computes the checksum address: ${ test.name }`, function() { + assert.equal(getAddress(test.address), test.address); + assert.equal(getAddress(test.icap), test.address); + assert.equal(getAddress(test.address.substring(2)), test.address); + assert.equal(getAddress(test.address.toLowerCase()), test.address); + assert.equal(getAddress("0x" + test.address.substring(2).toUpperCase()), test.address); + }); + } + + const invalidAddresses: Array<{ name: string, value: any }> = [ + { name: "null", value: null }, + { name: "number", value: 1234 }, + { name: "emtpy bytes", value: "0x" }, + { name: "too short", value: "0x8ba1f109551bd432803012645ac136ddd64dba" }, + { name: "too long", value: "0x8ba1f109551bd432803012645ac136ddd64dba7200" }, + ]; + + invalidAddresses.forEach(({ name, value }) => { + it(`fails on invalid address: ${ name }`, function() { + assert.throws(function() { + getAddress(value); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^invalid address/) && + error.argument === "address" && + error.value === value); + }); + }); + }); + + it("fails on invalid checksum", function() { + const value = "0x8ba1f109551bD432803012645Ac136ddd64DBa72" + assert.throws(function() { + getAddress(value); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^bad address checksum/) && + error.argument === "address" && + error.value === value); + }); + }); + + it("fails on invalid IBAN checksum", function() { + const value = "XE65GB6LDNXYOFTX0NSV3FUWKOWIXAMJK37"; + assert.throws(function() { + getAddress(value); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^bad icap checksum/) && + error.argument === "address" && + error.value === value); + }); + }); +}); + +describe("computes ICAP address", function() { + const tests = loadTests("accounts"); + for (const test of tests) { + it(`computes the ICAP address: ${ test.name }`, function() { + assert.equal(getIcapAddress(test.address), test.icap); + assert.equal(getAddress(test.address.toLowerCase()), test.address); + assert.equal(getAddress("0x" + test.address.substring(2).toUpperCase()), test.address); + }); + } +}); + +describe("computes create address", function() { + const tests = loadTests("create"); + for (const { sender, creates } of tests) { + for (const { name, nonce, address } of creates) { + it(`computes the create address: ${ name }`, function() { + assert.equal(getCreateAddress({ from: sender, nonce }), address); + }); + } + } +}); + +describe("computes create2 address", function() { + const tests = loadTests("create2"); + for (const { sender, creates } of tests) { + for (const { name, salt, initCodeHash, address } of creates) { + it(`computes the create2 address: ${ name }`, function() { + assert.equal(getCreate2Address(sender, salt, initCodeHash), address); + }); + } + } + + const sender = "0x8ba1f109551bD432803012645Ac136ddd64DBA72"; + const salt = "0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8"; + const initCodeHash = "0x8452c9b9140222b08593a26daa782707297be9f7b3e8281d7b4974769f19afd0"; + + it("fails on invalid salt", function() { + const badSalt = "0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36dea"; + assert.throws(function() { + getCreate2Address(sender, badSalt, initCodeHash); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.argument === "salt" && + error.value === badSalt); + }); + }); + + it("fails on invalid initCodeHash", function() { + const badInitCodeHash = "0x8452c9b9140222b08593a26daa782707297be9f7b3e8281d7b4974769f19af"; + assert.throws(function() { + getCreate2Address(sender, salt, badInitCodeHash); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.argument === "initCodeHash" && + error.value === badInitCodeHash); + }); + }); +}); diff --git a/src.ts/_tests/test-contract.ts b/src.ts/_tests/test-contract.ts new file mode 100644 index 000000000..c1062129e --- /dev/null +++ b/src.ts/_tests/test-contract.ts @@ -0,0 +1,99 @@ +/* +import { Typed } from "../abi/index.js"; +import * as providers from "../providers/index.js"; + +import { Contract } from "../index.js"; + +import { log } from "./utils.js"; +*/ +//import type { Addressable } from "@ethersproject/address"; +//import type { BigNumberish } from "@ethersproject/logger"; + +/* +import type { + ConstantContractMethod, ContractMethod, ContractEvent +} from "../index.js"; +*/ + +// @TODO +/* +describe("Test Contract Calls", function() { + it("finds typed methods", async function() { + const contract = new Contract("0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72", [ + "function foo(string s) view returns (uint)", + "function foo(uint8) view returns (uint)", + "function foo(uint u, bool b) view returns (uint)", + ]); + const value = Typed.string("42"); + await contract.foo.populateTransaction(value, Typed.overrides({ value: 100 })) + contract["foo(string)"].fragment + }); +}); +*/ +/* +describe("Test Contract Interface", function() { + it("builds contract interfaces", async function() { + this.timeout(60000); + + interface Erc20Interface { + // Constant Methods + balanceOf: ConstantContractMethod<[ address: string | Addressable ], bigint>; + decimals: ConstantContractMethod<[ ], bigint>; + + name: ConstantContractMethod<[ ], string>; + symbol: ConstantContractMethod<[ ], string>; + + // Mutation Methods + transferFrom: ContractMethod<[ address: string | Addressable, + address: string | Addressable, amount: BigNumberish ], boolean>; + + // Events + filters: { + Transfer: ContractEvent<[ from: Addressable | string, to: BigNumberish ]>; + } + } + + const erc20Abi = [ + "function balanceOf(address owner) view returns (uint)", + "function decimals() view returns (uint)", + "function name() view returns (string)", + "function symbol() view returns (string)", + + "function transferFrom(address from, address to, uint amount) returns (boolean)", + + "event Transfer(address indexed from, address indexed to, uint amount)" + ]; + + class Erc20Contract extends BaseContract.buildClass(erc20Abi) { }; + + const provider = new providers.InfuraProvider(); + // ENS + //const addr = "0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72"; + // DAI + const addr = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; + const contract = new Erc20Contract(addr, provider); + console.log("SYMBOL", await contract.symbol()); + console.log("DECIMALS", await contract.decimals()); + console.log(await contract.balanceOf("0x5555763613a12D8F3e73be831DFf8598089d3dCa")); + console.log(await contract.balanceOf("ricmoo.eth")); + + await contract.on(contract.filters.Transfer, (from, to, value, event) => { + console.log("HELLO!", { from, to, value, event }); + event.removeListener(); + }); + const logs = await contract.queryFilter("Transfer", -10); + console.log(logs, logs[0], logs[0].args.from); + }); +}); +*/ +/* +describe("Test Contract Calls", function() { + it("calls ERC-20 methods", async function() { + const provider = new providers.AnkrProvider(); + const contract = new Contract("0xC18360217D8F7Ab5e7c516566761Ea12Ce7F9D72", [ + "function balanceOf(address owner) view returns (uint)", + ], provider); + log(this, `balance: ${ await contract.balanceOf("0x5555763613a12D8F3e73be831DFf8598089d3dCa") }`); + }); +}); +*/ diff --git a/src.ts/_tests/test-crypto-algoswap.ts b/src.ts/_tests/test-crypto-algoswap.ts new file mode 100644 index 000000000..de7837a92 --- /dev/null +++ b/src.ts/_tests/test-crypto-algoswap.ts @@ -0,0 +1,116 @@ +import assert from "assert"; + +import { + lock, + + computeHmac, + + keccak256, ripemd160, sha256, sha512, + + pbkdf2, scrypt, scryptSync +} from "../index.js"; + +interface Algorithm { + (...args: Array): string | Promise; + + register: (func: any) => void; + lock: () => void; + _: (...args: Array) => any; +} + +interface TestCase { + name: string; + params: Array; + algorithm: Algorithm; + hijackTag: string; +} + + +describe("test registration", function() { + + let hijack = ""; + function getHijack(algo: string) { + return function(...args: Array) { + hijack = `hijacked ${ algo }: ${ JSON.stringify(args) }`; + return "0x42"; + } + } + + const tests: Array = [ + { + name: "keccak256", + params: [ "0x" ], + hijackTag: 'hijacked keccak256: [{}]', + algorithm: keccak256 + }, + { + name: "sha256", + params: [ "0x" ], + hijackTag: 'hijacked sha256: [{}]', + algorithm: sha256 + }, + { + name: "sha512", + params: [ "0x" ], + hijackTag: 'hijacked sha512: [{}]', + algorithm: sha512 + }, + { + name: "ripemd160", + params: [ "0x" ], + hijackTag: 'hijacked ripemd160: [{}]', + algorithm: ripemd160 + }, + { + name: "pbkdf2", + params: [ "0x", "0x", 1024, 32, "sha256" ], + hijackTag: 'hijacked pbkdf2: [{},{},1024,32,"sha256"]', + algorithm: pbkdf2 + }, + { + name: "scryptSync", + params: [ "0x", "0x", 1024, 8, 1, 32 ], + hijackTag: 'hijacked scryptSync: [{},{},1024,8,1,32]', + algorithm: scryptSync + }, + { + name: "scrypt", + params: [ "0x", "0x", 1024, 8, 1, 32 ], + hijackTag: 'hijacked scrypt: [{},{},1024,8,1,32,null]', + algorithm: scrypt + }, + { + name: "computeHmac", + params: [ "sha256", "0x", "0x" ], + hijackTag: 'hijacked computeHmac: ["sha256",{},{}]', + algorithm: computeHmac + }, + ]; + + tests.forEach(({ name, params, hijackTag, algorithm }) => { + it(`swaps in hijacked callback: ${ name }`, async function() { + const initial = await algorithm(...params); + + algorithm.register(getHijack(name)); + + assert.equal(await algorithm(...params), "0x42"); + assert.equal(hijack, hijackTag); + + algorithm.register(algorithm._); + assert.equal(await algorithm(...params), initial); + }); + }); + + it("prevents swapping after locked", function() { + lock(); + + tests.forEach(({ name, params, hijackTag, algorithm }) => { + assert.throws(function() { + algorithm.register(getHijack("test")); + }, function(error: any) { + return (error.message === `${ name } is locked`); + }); + }); + }); + +}); diff --git a/src.ts/_tests/test-crypto.ts b/src.ts/_tests/test-crypto.ts new file mode 100644 index 000000000..b8b1b17aa --- /dev/null +++ b/src.ts/_tests/test-crypto.ts @@ -0,0 +1,238 @@ +import assert from "assert"; + +import { loadTests } from "./utils.js"; + +import type { TestCaseHash, TestCaseHmac, TestCasePbkdf } from "./types.js"; + +import { + computeHmac, + keccak256, ripemd160, sha256, sha512, + pbkdf2, scrypt, scryptSync +} from "../index.js"; + + +describe("test hashing", function() { + const tests = loadTests("hashes"); + + tests.forEach((test) => { + it(`computes sha2-256: ${ test.name }`, function() { + assert.equal(sha256(test.data), test.sha256); + }); + }); + + tests.forEach((test) => { + it(`computes sha2-512: ${ test.name }`, function() { + assert.equal(sha512(test.data), test.sha512); + }); + }); + + tests.forEach((test) => { + it(`computes ripemd160: ${ test.name }`, function() { + assert.equal(ripemd160(test.data), test.ripemd160); + }); + }); + + tests.forEach((test) => { + it(`computes keccak256: ${ test.name }`, function() { + assert.equal(keccak256(test.data), test.keccak256); + }); + }); +}); + +describe("test password-based key derivation", function() { + const tests = loadTests("pbkdf"); + + tests.forEach((test) => { + it(`computes pbkdf2: ${ test.name}`, function() { + const password = Buffer.from(test.password.substring(2), "hex"); + const salt = Buffer.from(test.salt.substring(2), "hex"); + const { iterations, algorithm, key } = test.pbkdf2; + const result = pbkdf2(password, salt, iterations, test.dkLen, algorithm); + assert.equal(result, key); + }); + }); + + tests.forEach((test) => { + it(`computes scrypt (sync): ${ test.name}`, function() { + this.timeout(1000); + + const password = Buffer.from(test.password.substring(2), "hex"); + const salt = Buffer.from(test.salt.substring(2), "hex"); + const { N, r, p, key } = test.scrypt; + const result = scryptSync(password, salt, N, r, p, test.dkLen); + assert.equal(result, key); + }); + }); + + tests.forEach((test) => { + it(`computes scrypt (async): ${ test.name}`, async function() { + this.timeout(1000); + + const password = Buffer.from(test.password.substring(2), "hex"); + const salt = Buffer.from(test.salt.substring(2), "hex"); + const { N, r, p, key } = test.scrypt; + + let progressCount = 0, progressOk = true, lastProgress = -1; + + const result = await scrypt(password, salt, N, r, p, test.dkLen, (progress) => { + if (progress < lastProgress) { progressOk = false; } + lastProgress = progress; + progressCount++; + }); + + assert.ok(progressOk, "progress was not monotonically increasing"); + assert.ok(progressCount > 100, "progress callback was called at leat 100 times"); + assert.equal(result, key); + }); + }); + +}); + +describe("test hmac", function() { + const tests = loadTests("hmac"); + + tests.forEach((test) => { + it(`computes hmac: ${ test.name}`, async function() { + const { algorithm, key, data } = test; + assert.equal(computeHmac(algorithm, key, data), test.hmac); + }); + }); +}); + +/* +describe("test registration", function() { + let hijack = ""; + function getHijack(algo: string) { + return function(...args: Array) { + hijack = `hijacked ${ algo }: ${ JSON.stringify(args) }`; + return "0x42"; + } + } + + it("hijacks keccak256", function() { + const initial = keccak256("0x"); + + keccak256.register(getHijack("kecak256")); + assert.equal(keccak256("0x"), "0x42"); + assert.equal(hijack, 'hijacked kecak256: [{}]'); + + keccak256.register(keccak256._); + assert.equal(keccak256("0x"), initial); + + keccak256.lock(); + + assert.throws(function() { + keccak256.register(getHijack("test")); + }, function(error) { + return (error.message === "keccak256 is locked"); + }); + }); + + it("hijacks sha256", function() { + const initial = sha256("0x"); + + sha256.register(getHijack("sha256")); + assert.equal(sha256("0x"), "0x42"); + assert.equal(hijack, 'hijacked sha256: [{}]'); + + sha256.register(sha256._); + assert.equal(sha256("0x"), initial); + + sha256.lock(); + + assert.throws(function() { + sha256.register(getHijack("test")); + }, function(error) { + return (error.message === "sha256 is locked"); + }); + }); + + it("hijacks sha512", function() { + const initial = sha512("0x"); + + sha512.register(getHijack("sha512")); + assert.equal(sha512("0x"), "0x42"); + assert.equal(hijack, 'hijacked sha512: [{}]'); + + sha512.register(sha512._); + assert.equal(sha512("0x"), initial); + + sha512.lock(); + + assert.throws(function() { + sha512.register(getHijack("test")); + }, function(error) { + return (error.message === "sha512 is locked"); + }); + }); + + it("hijacks pbkdf2", function() { + const initial = pbkdf2("0x", "0x", 1024, 32, "sha256"); + + pbkdf2.register(getHijack("pbkdf2")); + assert.equal(pbkdf2("0x", "0x", 1024, 32, "sha256"), "0x42"); + assert.equal(hijack, 'hijacked pbkdf2: [{},{},1024,32,"sha256"]'); + + pbkdf2.register(pbkdf2._); + assert.equal(pbkdf2("0x", "0x", 1024, 32, "sha256"), initial); + + pbkdf2.lock(); + + assert.throws(function() { + pbkdf2.register(getHijack("test")); + }, function(error) { + return (error.message === "pbkdf2 is locked"); + }); + }); + + it("hijacks scryptSync", function() { + + function getHijack(...args: Array) { + hijack = `hijacked scryptSync: ${ JSON.stringify(args) }`; + return new Uint8Array([ 0x42 ]); + } + + const initial = scryptSync("0x", "0x", 1024, 8, 1, 32); + + scryptSync.register(getHijack); + assert.equal(scryptSync("0x", "0x", 1024, 8, 1, 32), "0x42"); + assert.equal(hijack, 'hijacked scryptSync: [{},{},1024,8,1,32]'); + + scryptSync.register(scryptSync._); + assert.equal(scryptSync("0x", "0x", 1024, 8, 1, 32), initial); + + scryptSync.lock(); + + assert.throws(function() { + scryptSync.register(getHijack); + }, function(error) { + return (error.message === "scryptSync is locked"); + }); + }); + + it("hijacks scrypt", async function() { + function getHijack(...args: Array) { + hijack = `hijacked scrypt: ${ JSON.stringify(args) }`; + return Promise.resolve(new Uint8Array([ 0x42 ])); + } + + const initial = await scrypt("0x", "0x", 1024, 8, 1, 32); + + scrypt.register(getHijack); + assert.equal(await scrypt("0x", "0x", 1024, 8, 1, 32), "0x42"); + assert.equal(hijack, 'hijacked scrypt: [{},{},1024,8,1,32,null]'); + + scrypt.register(scrypt._); + assert.equal(await scrypt("0x", "0x", 1024, 8, 1, 32), initial); + + scrypt.lock(); + + assert.throws(function() { + scrypt.register(getHijack); + }, function(error) { + return (error.message === "scrypt is locked"); + }); + }); + +}); +*/ diff --git a/src.ts/_tests/test-hash-typeddata.ts b/src.ts/_tests/test-hash-typeddata.ts new file mode 100644 index 000000000..bf9a6ab50 --- /dev/null +++ b/src.ts/_tests/test-hash-typeddata.ts @@ -0,0 +1,20 @@ +import assert from "assert"; +import { loadTests } from "./utils.js"; +import type { TestCaseTypedData } from "./types.js"; + +import { TypedDataEncoder } from "../index.js"; + + +describe("Tests Typed Data (EIP-712)", function() { + const tests = loadTests("typed-data"); + for (const test of tests) { + it(`tests encoding typed-data: ${ test.name }`, function() { + const encoder = TypedDataEncoder.from(test.types); + assert.equal(encoder.primaryType, test.primaryType, "primaryType"); + assert.equal(encoder.encode(test.data), test.encoded, "encoded"); + + assert.equal(TypedDataEncoder.getPrimaryType(test.types), test.primaryType, "primaryType"); + assert.equal(TypedDataEncoder.hash(test.domain, test.types, test.data), test.digest, "digest"); + }); + } +}); diff --git a/src.ts/_tests/test-hash.ts b/src.ts/_tests/test-hash.ts new file mode 100644 index 000000000..f8e4d314d --- /dev/null +++ b/src.ts/_tests/test-hash.ts @@ -0,0 +1,110 @@ +/* +import assert from "assert"; +import { loadTests } from "./utils.js" +import type { TestCaseNamehash } from "./types.js"; + +import { dnsEncode, isValidName, namehash } from "../index.js"; + +describe("Tests Namehash", function() { + const tests = loadTests("namehash"); + for (const test of tests) { + it(`hashes ENS names: ${ JSON.stringify(test.ensName) }`, function() { + const actual = namehash(test.ensName); + + assert.equal(actual, test.namehash, "namehash"); + + // The empty string is not a valid ENS name + if (test.ensName) { + assert.ok(isValidName(test.ensName), "isValidName"); + } + }); + } +}); + +describe("Tests Bad ENS Names", function() { + const badTests: Array<{ ensName: any, prefix: string }> = [ + { ensName: ".", prefix: "missing component" }, + { ensName:"..", prefix: "missing component" }, + { ensName:"ricmoo..eth", prefix: "missing component" }, + { ensName:"ricmoo...eth", prefix: "missing component" }, + { ensName:".foo", prefix: "missing component" }, + { ensName:"foo.", prefix: "missing component" }, + { ensName: 1234, prefix: "not a string" }, + { ensName: true, prefix: "not a string" }, + ]; + + // The empty string is not a valid name, but has a valid namehash + // (the zero hash) as it is the base case for recursion + it("empty ENS name", function() { + assert.ok(!isValidName(""), "!isValidName"); + }); + + for (const { ensName, prefix } of badTests) { + it(`fails on bad ENS name: ${ JSON.stringify(ensName) }`, function() { + assert.ok(!isValidName(ensName), "!isValidName"); + assert.throws(() => { + const result = namehash(ensName); + console.log(result); + }, (error) => { + const errorPrefix = `invalid ENS name; ${ prefix }`; + return (error.code === "INVALID_ARGUMENT" && + error.argument === "name" && error.value === ensName && + error.message.substring(0, errorPrefix.length) === errorPrefix); + }); + }); + } +}); + +describe("Tests DNS Encoding", function() { + const tests: Array<{ ensName: string, dnsEncoded: string}> = [ + { ensName: "", dnsEncoded: "0x00" }, + { ensName: "ricmoo.eth", dnsEncoded: "0x067269636d6f6f0365746800" }, + ]; + + for (const { ensName, dnsEncoded } of tests) { + it(`computes the DNS Encoding: ${ JSON.stringify(ensName) }`, function() { + assert.equal(dnsEncode(ensName), dnsEncoded, "dnsEncoded"); + }); + } +}); + +describe("Tests DNS Names", function() { + const badTests: Array<{ ensName: any, prefix: string}> = [ + { ensName: ".", prefix: "invalid DNS name; missing component" }, + { ensName: "foo..bar", prefix: "invalid DNS name; missing component" }, + { ensName: ".foo", prefix: "invalid DNS name; missing component" }, + { ensName: "foo.", prefix: "invalid DNS name; missing component" }, + { ensName: 1234, prefix: "invalid DNS name; not a string" }, + { ensName: true, prefix: "invalid DNS name; not a string" }, + ]; + + for (const { ensName, prefix } of badTests) { + it(`fails on bad DNS name: ${ JSON.stringify(ensName) }`, function() { + assert.throws(() => { + const result = dnsEncode(ensName); + console.log(result); + }, (error) => { + return (error.code === "INVALID_ARGUMENT" && + error.argument === "name" && error.value === ensName && + error.message.substring(0, prefix.length) === prefix); + }); + }); + } + + { + const ensName = "foobar012345678901234567890123456789012345678901234567890123456789"; + const prefix = "too long"; + it(`fails on bad DNS name: ${ JSON.stringify(ensName) }`, function() { + assert.throws(() => { + const result = dnsEncode(ensName); + console.log(result); + }, (error) => { + return (error.code === "INVALID_ARGUMENT" && + error.argument === "value" && error.value === ensName && + error.message.substring(0, prefix.length) === prefix); + }); + }); + } + +}); +*/ diff --git a/src.ts/_tests/test-rlp.ts b/src.ts/_tests/test-rlp.ts new file mode 100644 index 000000000..b1fb7e49d --- /dev/null +++ b/src.ts/_tests/test-rlp.ts @@ -0,0 +1,101 @@ +import assert from "assert"; + +import { loadTests } from "./utils.js"; + +import { decodeRlp, encodeRlp } from "../index.js"; + +import type { TestCaseRlp } from "./types.js"; + +describe("Test RLP Coder", function() { + + const tests = loadTests("rlp"); + + tests.forEach(({ name, encoded, decoded }) => { + it(`encodes RLP: ${ name }`, function() { + assert.equal(encodeRlp(decoded), encoded); + }); + }); + + tests.forEach(({ name, encoded, decoded }) => { + it(`decodes RLP: ${ name }`, function() { + assert.deepStrictEqual(decodeRlp(encoded), decoded); + }); + }); +}); + +describe("Test bad RLP Data", function() { + it("fails encoding data with invalid values", function() { + assert.throws(() => { + encodeRlp([ "0x1234", 1234 ]); + }, (error: any) => { + return (error.code === "INVALID_ARGUMENT" && + error.argument === "object" && + error.value === 1234) + }); + }); + + it("fails decoding data with trailing junk", function() { + assert.throws(() => { + // Zeros_1 + decodeRlp("0x0042"); + }, (error: any) => { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^unexpected junk after rlp payload/) && + error.argument === "data" && + error.value === "0x0042") + }); + }); + + it ("fails decoding short data", function() { + assert.throws(() => { + decodeRlp("0x"); + }, (error: any) => { + return (error.code === "BUFFER_OVERRUN" && + error.message.match(/^data too short/) && + Buffer.from(error.buffer).toString("hex") === "" && + error.offset === 1 && + error.length === 0) + }); + }); + + it ("fails decoding short data in child", function() { + assert.throws(() => { + decodeRlp("0xc8880102030405060708"); + }, (error: any) => { + return (error.code === "BUFFER_OVERRUN" && + error.message.match(/^child data too short/) && + Buffer.from(error.buffer).toString("hex") === "c8880102030405060708" && + error.offset === 0 && + error.length === 8) + }); + }); + + it ("fails decoding short segment data", function() { + assert.throws(() => { + // [["0x4243"], ["0x3145"]] = 0xc8 c3 82 4243 c3 82 3145 + // XXXX + decodeRlp("0xc8c382c3823145"); + }, (error: any) => { + return (error.code === "BUFFER_OVERRUN" && + error.message.match(/^data short segment too short/) && + Buffer.from(error.buffer).toString("hex") === "c8c382c3823145" && + error.offset === 9 && + error.length === 7) + }); + }); +}); + +/* + utils.RLP.encode([["0x4243"], ["0x3145"]]) + + 0xc8 c3 82 4243 c3 82 3145 + + { + "name": "arrayShort2", + "decoded": [ + "0x48656c6c6f20576f726c64", + "0x48656c6c6f20576f726c64" + ], + "encoded": "0xd8 8b 48656c6c6f20576f726c64 8b 48656c6c6f20576f726c64" + }, +*/ diff --git a/src.ts/_tests/test-transaction.ts b/src.ts/_tests/test-transaction.ts new file mode 100644 index 000000000..c8244b7eb --- /dev/null +++ b/src.ts/_tests/test-transaction.ts @@ -0,0 +1,306 @@ +import assert from "assert"; +import { loadTests } from "./utils.js"; +import type { TestCaseTransaction, TestCaseTransactionTx } from "./types.js"; + + +import { Transaction } from "../index.js"; + + +const BN_0 = BigInt(0); + +describe("Tests Unsigned Transaction Serializing", function() { + const tests = loadTests("transactions"); + + for (const test of tests) { + it(`serialized unsigned legacy transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { + type: 0, + accessList: undefined, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined + }); + + // Use the testcase sans the chainId for a legacy test + if (txData.chainId != null && parseInt(txData.chainId) != 0) { txData.chainId = "0x00"; } + + const tx = Transaction.from(txData); + assert.equal(tx.unsignedSerialized, test.unsignedLegacy, "unsignedLegacy"); + }); + } + + for (const test of tests) { + // Unsupported parameters for EIP-155; i.e. unspecified chain ID + if (!test.unsignedEip155) { continue; } + it(`serialized unsigned EIP-155 transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { + type: 0, + accessList: undefined, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined + }); + + const tx = Transaction.from(txData); + assert.equal(tx.unsignedSerialized, test.unsignedEip155, "unsignedEip155"); + }); + } + + for (const test of tests) { + it(`serialized unsigned Berlin transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { + type: 1, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined + }); + + const tx = Transaction.from(txData); + assert.equal(tx.unsignedSerialized, test.unsignedBerlin, "unsignedBerlin"); + }); + } + + for (const test of tests) { + it(`serialized unsigned London transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { type: 2 }); + const tx = Transaction.from(txData); + assert.equal(tx.unsignedSerialized, test.unsignedLondon, "unsignedLondon"); + }); + } +}); + +describe("Tests Signed Transaction Serializing", function() { + const tests = loadTests("transactions"); + + for (const test of tests) { + it(`serialized signed legacy transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { + type: 0, + accessList: undefined, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + signature: test.signatureLegacy + }); + + // Use the testcase sans the chainId for a legacy test + if (txData.chainId != null && parseInt(txData.chainId) != 0) { txData.chainId = "0x00"; } + + const tx = Transaction.from(txData); + assert.equal(tx.serialized, test.signedLegacy, "signedLegacy"); + }); + } + + for (const test of tests) { + if (!test.unsignedEip155) { continue; } + it(`serialized signed EIP-155 transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { + type: 0, + accessList: undefined, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + signature: test.signatureEip155 + }); + + const tx = Transaction.from(txData); + assert.equal(tx.serialized, test.signedEip155, "signedEip155"); + }); + } + + for (const test of tests) { + it(`serialized signed Berlin transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { + type: 1, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined + }, { signature: test.signatureBerlin }); + + const tx = Transaction.from(txData); + assert.equal(tx.serialized, test.signedBerlin, "signedBerlin"); + }); + } + + for (const test of tests) { + it(`serialized signed London transaction: ${ test.name }`, function() { + const txData = Object.assign({ }, test.transaction, { + type: 2, + signature: test.signatureLondon + }); + + const tx = Transaction.from(txData); + assert.equal(tx.serialized, test.signedLondon, "signedLondon"); + }); + } +}); + +function assertTxUint(actual: null | bigint, _expected: undefined | string, name: string): void { + const expected = (_expected != null ? BigInt(_expected): null); + assert.equal(actual, expected, name); +} + +function assertTxEqual(actual: Transaction, expected: TestCaseTransactionTx): void { + assert.equal(actual.to, expected.to, "to"); + assert.equal(actual.nonce, expected.nonce, "nonce"); + + assertTxUint(actual.gasLimit, expected.gasLimit, "gasLimit"); + + assertTxUint(actual.gasPrice, expected.gasPrice, "gasPrice"); + assertTxUint(actual.maxFeePerGas, expected.maxFeePerGas, "maxFeePerGas"); + assertTxUint(actual.maxPriorityFeePerGas, expected.maxPriorityFeePerGas, "maxPriorityFeePerGas"); + + assert.equal(actual.data, expected.data, "data"); + assertTxUint(actual.value, expected.value, "value"); + + if (expected.accessList) { + assert.equal(JSON.stringify(actual.accessList), JSON.stringify(expected.accessList), "accessList"); + } else { + assert.equal(actual.accessList, null, "accessList:!null"); + } + + assertTxUint(actual.chainId, expected.chainId, "chainId"); +} + +function addDefault(tx: any, key: string, defaultValue: any): void { + if (tx[key] == null) { tx[key] = defaultValue; } +} + +function addDefaults(tx: any): any { + tx = Object.assign({ }, tx); + addDefault(tx, "nonce", 0); + addDefault(tx, "gasLimit", BN_0); + addDefault(tx, "gasPrice", BN_0); + addDefault(tx, "maxFeePerGas", BN_0); + addDefault(tx, "maxPriorityFeePerGas", BN_0); + addDefault(tx, "value", BN_0); + addDefault(tx, "data", "0x"); + addDefault(tx, "accessList", [ ]); + addDefault(tx, "chainId", BN_0); + return tx; +} + +describe("Tests Unsigned Transaction Parsing", function() { + const tests = loadTests("transactions"); + + for (const test of tests) { + it(`parses unsigned legacy transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.unsignedLegacy); + + const expected = addDefaults(test.transaction); + expected.maxFeePerGas = null; + expected.maxPriorityFeePerGas = null; + expected.accessList = null; + expected.chainId = BN_0; + + assertTxEqual(tx, expected); + }); + } + + for (const test of tests) { + if (!test.unsignedEip155) { continue; } + it(`parses unsigned EIP-155 transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.unsignedEip155); + + const expected = addDefaults(test.transaction); + expected.maxFeePerGas = null; + expected.maxPriorityFeePerGas = null; + expected.accessList = null; + + assertTxEqual(tx, expected); + }); + } + + for (const test of tests) { + it(`parses unsigned Berlin transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.unsignedBerlin); + + const expected = addDefaults(test.transaction); + expected.maxFeePerGas = null; + expected.maxPriorityFeePerGas = null; + + assertTxEqual(tx, expected); + }); + } + + for (const test of tests) { + it(`parses unsigned London transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.unsignedLondon); + + const expected = addDefaults(test.transaction); + expected.gasPrice = null; + + assertTxEqual(tx, expected); + }); + } +}); + +describe("Tests Signed Transaction Parsing", function() { + const tests = loadTests("transactions"); + + for (const test of tests) { + it(`parses signed legacy transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.signedLegacy); + + const expected = addDefaults(test.transaction); + expected.maxFeePerGas = null; + expected.maxPriorityFeePerGas = null; + expected.accessList = null; + expected.chainId = BN_0; + + assertTxEqual(tx, expected); + + assert.ok(!!tx.signature, "signature:!null") + assert.equal(tx.signature.r, test.signatureLegacy.r, "signature.r"); + assert.equal(tx.signature.s, test.signatureLegacy.s, "signature.s"); + assert.equal(BigInt(tx.signature.v), BigInt(test.signatureLegacy.v), "signature.v"); + }); + } + + for (const test of tests) { + if (!test.unsignedEip155) { continue; } + it(`parses signed EIP-155 transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.signedEip155); + + const expected = addDefaults(test.transaction); + expected.maxFeePerGas = null; + expected.maxPriorityFeePerGas = null; + expected.accessList = null; + + assertTxEqual(tx, expected); + + assert.ok(!!tx.signature, "signature:!null") + assert.equal(tx.signature.r, test.signatureEip155.r, "signature.r"); + assert.equal(tx.signature.s, test.signatureEip155.s, "signature.s"); + assert.equal(tx.signature.networkV, BigInt(test.signatureEip155.v), "signature.v"); + }); + } + + for (const test of tests) { + it(`parses signed Berlin transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.signedBerlin); + + const expected = addDefaults(test.transaction); + expected.maxFeePerGas = null; + expected.maxPriorityFeePerGas = null; + + assertTxEqual(tx, expected); + + assert.ok(!!tx.signature, "signature:!null") + assert.equal(tx.signature.r, test.signatureBerlin.r, "signature.r"); + assert.equal(tx.signature.s, test.signatureBerlin.s, "signature.s"); + assert.equal(tx.signature.yParity, parseInt(test.signatureBerlin.v), "signature.v"); + }); + } + + for (const test of tests) { + it(`parses signed London transaction: ${ test.name }`, function() { + const tx = Transaction.from(test.signedLondon); + + const expected = addDefaults(test.transaction); + expected.gasPrice = null; + + assertTxEqual(tx, expected); + + assert.ok(!!tx.signature, "signature:!null") + assert.equal(tx.signature.r, test.signatureLondon.r, "signature.r"); + assert.equal(tx.signature.s, test.signatureLondon.s, "signature.s"); + assert.equal(tx.signature.yParity, parseInt(test.signatureLondon.v), "signature.v"); + }); + } +}); + diff --git a/src.ts/_tests/test-wallet-hd.ts b/src.ts/_tests/test-wallet-hd.ts new file mode 100644 index 000000000..f1d0e5f89 --- /dev/null +++ b/src.ts/_tests/test-wallet-hd.ts @@ -0,0 +1,155 @@ + +import assert from "assert"; + +import { wordlists } from "../wordlists/wordlists.js"; + +import { loadTests } from "./utils.js"; + +import { HDNodeWallet, HDNodeVoidWallet, Mnemonic } from "../index.js"; + +import type { Wordlist } from "../wordlists/index.js"; + +import type { TestCaseMnemonic, TestCaseMnemonicNode } from "./types.js"; + + +declare global { + class TextDecoder { + decode(data: Uint8Array): string; + } +} + + +const decoder = new TextDecoder(); +function fromHex(hex: string): string { + const data = Buffer.from(hex.substring(2), "hex"); + return decoder.decode(data); +} + +type Test = { + phrase: string; + password: string; + wordlist: Wordlist; + mnemonic: Mnemonic; + checkMnemonic: (a: Mnemonic) => void; + test: TestCaseMnemonic; +}; + +describe("Test HDWallets", function() { + function checkWallet(wallet: HDNodeWallet | HDNodeVoidWallet, test: TestCaseMnemonicNode): void { + assert.equal(wallet.chainCode, test.chainCode, "chainCode"); + assert.equal(wallet.depth, test.depth, "depth"); + assert.equal(wallet.index, test.index, "index"); + assert.equal(wallet.fingerprint, test.fingerprint, "fingerprint"); + assert.equal(wallet.parentFingerprint, test.parentFingerprint, "parentFingerprint"); + assert.equal(wallet.publicKey, test.publicKey, "publicKey"); + + if (wallet instanceof HDNodeWallet) { + assert.equal(wallet.extendedKey, test.xpriv, "xpriv"); + assert.equal(wallet.privateKey, test.privateKey, "privateKey"); + assert.equal(wallet.neuter().extendedKey, test.xpub, "xpub"); + } else if (wallet instanceof HDNodeVoidWallet) { + assert.equal(wallet.extendedKey, test.xpub, "xpub"); + } + } + + const tests = loadTests("mnemonics"); + + const checks: Array = [ ]; + tests.forEach((test) => { + // The phrase and password are stored in the test as hex so they + // are safe as ascii7 values for viewing, printing, etc. + const phrase = fromHex(test.phrase); + const password = fromHex(test.password); + const wordlist = wordlists[test.locale]; + if (wordlist == null) { + it(`tests ${ test.name }`, function() { + this.skip(); + }); + return; + } + + const mnemonic = Mnemonic.fromPhrase(phrase, password, wordlist); + + function checkMnemonic(actual: Mnemonic): void { + assert.equal(actual.phrase, phrase, "phrase"); + assert.equal(actual.password, password, "password"); + assert.equal(actual.wordlist.locale, test.locale, "locale"); + assert.equal(actual.entropy, mnemonic.entropy, "entropy"); + assert.equal(actual.computeSeed(), mnemonic.computeSeed(), "seed"); + } + + checks.push({ + phrase, password, wordlist, mnemonic, checkMnemonic, test + }); + }); + + for (const { test, checkMnemonic, phrase, password, wordlist } of checks) { + it(`computes the HD keys by mnemonic: ${ test.name }`, function() { + for (const subtest of test.nodes) { + const w = HDNodeWallet.fromPhrase(phrase, password, subtest.path, wordlist); + assert.ok(w instanceof HDNodeWallet, "instanceof HDNodeWallet"); + assert.equal(w.path, subtest.path, "path") + checkWallet(w, subtest); + assert.ok(!!w.mnemonic, "has mnemonic"); + checkMnemonic(w.mnemonic as Mnemonic); + } + }); + } + + for (const { test } of checks) { + it(`computes the HD keys by entropy: ${ test.name }`, function() { + const seedRoot = HDNodeWallet.fromSeed(test.seed); + for (const subtest of test.nodes) { + const w = seedRoot.derivePath(subtest.path); + assert.ok(w instanceof HDNodeWallet, "instanceof HDNodeWallet"); + assert.equal(w.path, subtest.path, "path") + checkWallet(w, subtest); + assert.equal(w.mnemonic, null); + } + }); + } + + for (const { test } of checks) { + it(`computes the HD keys by enxtended private key: ${ test.name }`, function() { + for (const subtest of test.nodes) { + const w = HDNodeWallet.fromExtendedKey(subtest.xpriv); + assert.ok(w instanceof HDNodeWallet, "instanceof HDNodeWallet"); + checkWallet(w, subtest); + assert.equal(w.mnemonic, null); + } + }); + } + + for (const { test, phrase, password, wordlist } of checks) { + it(`computes the neutered HD keys by paths: ${ test.name }`, function() { + const root = HDNodeWallet.fromPhrase(phrase, password, "m", wordlist).neuter(); + for (const subtest of test.nodes) { + if (subtest.path.indexOf("'") >= 0) { + assert.throws(() => { + const w = root.derivePath(subtest.path); + console.log(w); + }, (error: any) => { + return (error.code === "UNSUPPORTED_OPERATION" && + error.message.match(/^cannot derive child of neutered node/) && + error.operation === "deriveChild"); + }); + } else { + const w = root.derivePath(subtest.path); + assert.ok(w instanceof HDNodeVoidWallet, "instanceof HDNodeVoidWallet"); + assert.equal(w.path, subtest.path, "path") + checkWallet(w, subtest); + } + } + }); + } + + for (const { test } of checks) { + it(`computes the neutered HD keys by enxtended public key: ${ test.name }`, function() { + for (const subtest of test.nodes) { + const w = HDNodeWallet.fromExtendedKey(subtest.xpub); + assert.ok(w instanceof HDNodeVoidWallet, "instanceof HDNodeVoidWallet"); + checkWallet(w, subtest); + } + }); + } +}); diff --git a/src.ts/_tests/test-wallet-json.ts b/src.ts/_tests/test-wallet-json.ts new file mode 100644 index 000000000..de4a0b3da --- /dev/null +++ b/src.ts/_tests/test-wallet-json.ts @@ -0,0 +1,65 @@ +import assert from "assert"; + +import { loadTests } from "./utils.js"; + +import type { TestCaseWallet } from "./types.js"; + +import { + decryptCrowdsaleJson, + decryptKeystoreJson, decryptKeystoreJsonSync, + Wallet +} from "../index.js"; + +describe("Tests JSON Wallet Formats", function() { + const tests = loadTests("wallets"); + tests.forEach((test) => { + if (test.type !== "crowdsale") { return; } + it(`tests decrypting Crowdsale JSON: ${ test.name }`, async function() { + const password = Buffer.from(test.password.substring(2), "hex"); + const account = decryptCrowdsaleJson(test.content, password); + assert.equal(account.address, test.address, "address"); + }); + }); + + tests.forEach((test) => { + if (test.type !== "keystore") { return; } + it(`tests decrypting Keystore JSON (sync): ${ test.name }`, function() { + this.timeout(20000); + const password = Buffer.from(test.password.substring(2), "hex"); + const account = decryptKeystoreJsonSync(test.content, password); + //console.log(account); + assert.equal(account.address, test.address, "address"); + }); + }); + + tests.forEach((test) => { + if (test.type !== "keystore") { return; } + it(`tests decrypting Keystore JSON (async): ${ test.name }`, async function() { + this.timeout(20000); + const password = Buffer.from(test.password.substring(2), "hex"); + const account = await decryptKeystoreJson(test.content, password); + //console.log(account); + assert.equal(account.address, test.address, "address"); + }); + }); + + tests.forEach((test) => { + it(`tests decrypting JSON (sync): ${ test.name }`, function() { + this.timeout(20000); + const password = Buffer.from(test.password.substring(2), "hex"); + const wallet = Wallet.fromEncryptedJsonSync(test.content, password); + //console.log(wallet); + assert.equal(wallet.address, test.address, "address"); + }); + }); + + tests.forEach((test) => { + it(`tests decrypting JSON (async): ${ test.name }`, async function() { + this.timeout(20000); + const password = Buffer.from(test.password.substring(2), "hex"); + const wallet = await Wallet.fromEncryptedJson(test.content, password); + //console.log(wallet); + assert.equal(wallet.address, test.address, "address"); + }); + }); +}); diff --git a/src.ts/_tests/test-wallet-mnemonic.ts b/src.ts/_tests/test-wallet-mnemonic.ts new file mode 100644 index 000000000..2ec53a713 --- /dev/null +++ b/src.ts/_tests/test-wallet-mnemonic.ts @@ -0,0 +1,143 @@ + +import assert from "assert"; + +import { sha256 } from "../crypto/index.js"; +import { toUtf8Bytes } from "../utils/utf8.js"; + +import { wordlists } from "../wordlists/wordlists.js"; + +import { Mnemonic } from "../index.js"; + +import { loadTests } from "./utils.js"; +import type { TestCaseMnemonic } from "./types.js"; + +const decoder = new TextDecoder(); +function fromHex(hex: string): string { + const data = Buffer.from(hex.substring(2), "hex"); + return decoder.decode(data); +} + +function repeat(text: string, length: number): Array { + const result = [ ]; + while (result.length < length) { result.push(text); } + return result; +} + +describe("Tests Mnemonics", function() { + const tests = loadTests("mnemonics"); + + function runTest(phrase: string, mnemonic: Mnemonic, test: TestCaseMnemonic): void { + assert.ok(Mnemonic.isValidMnemonic(phrase, mnemonic.wordlist), "isValidMnemonic"); + if (test.locale === "en") { + assert.ok(Mnemonic.isValidMnemonic(phrase), "isValidMnemonic (default)"); + } + + assert.equal(mnemonic.wordlist.locale, test.locale, "locale"); + + assert.equal(mnemonic.entropy, test.entropy, "entropy"); + assert.equal(mnemonic.computeSeed(), test.seed, "seed"); + assert.equal(sha256(toUtf8Bytes(phrase)), test.phraseHash, "phraseHash") + } + + for (const test of tests) { + const wordlist = wordlists[test.locale]; + + it(`computes mnemonic from phrase: ${ test.name }`, function() { + if (wordlist == null) { + this.skip(); + return; + } + + const phrase = fromHex(test.phrase); + const password = fromHex(test.password); + const mnemonic = Mnemonic.fromPhrase(phrase, password, wordlist); + runTest(phrase, mnemonic, test); + }); + } + + for (const test of tests) { + const wordlist = wordlists[test.locale]; + + it(`computes mnemonic from entropy: ${ test.name }`, function() { + if (wordlist == null) { + this.skip(); + return; + } + const phrase = fromHex(test.phrase); + const password = fromHex(test.password); + const mnemonic = Mnemonic.fromEntropy(test.entropy, password, wordlist); + runTest(phrase, mnemonic, test); + }); + } +}); + +describe("Tests Bad Mnemonics Fail", function() { + + const badLengths = [ + repeat("abandon", 9), // 9 words; too short + repeat("abandon", 16), // 16 words; not congruent to 0 mod 3 + repeat("abandon", 27), // 27 words; too long + ]; + + for (const _phrase of badLengths) { + const phrase = _phrase.join(" "); + it(`fails on invalid mnemonic length: ${ _phrase.length }`, function() { + assert.ok(!Mnemonic.isValidMnemonic(phrase)); + assert.throws(function() { + Mnemonic.fromPhrase(phrase); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^invalid mnemonic length/) && + error.argument === "mnemonic" && + error.value === "[ REDACTED ]"); + }); + }); + } + + it("fails on invalid mnemonic word", function() { + const phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon wagmi"; + assert.ok(!Mnemonic.isValidMnemonic(phrase)); + assert.throws(function() { + Mnemonic.fromPhrase(phrase); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^invalid mnemonic word at index 11/) && + error.argument === "mnemonic" && + error.value === "[ REDACTED ]"); + }); + }); + + it("fails on invalid mnemonic checksum", function() { + const phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"; + assert.ok(!Mnemonic.isValidMnemonic(phrase)); + assert.throws(function() { + Mnemonic.fromPhrase(phrase); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^invalid mnemonic checksum/) && + error.argument === "mnemonic" && + error.value === "[ REDACTED ]"); + }); + }); + + + const badEntropyLengths = [ + repeat("42", 12), //12 bytes; too short + repeat("42", 15), // 16 bytes; not congruent to 0 mod 4 + repeat("42", 36), // 36 bytes; too long + ]; + + for (const _entropy of badEntropyLengths) { + const entropy = "0x" + _entropy.join(""); + it(`fails on invalid entropy length: ${ _entropy.length }`, function() { + assert.throws(function() { + Mnemonic.fromEntropy(entropy); + }, function(error: any) { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^invalid entropy size/) && + error.argument === "entropy" && + error.value === "[ REDACTED ]"); + }); + }); + } +}) diff --git a/src.ts/_tests/test-wallet.ts b/src.ts/_tests/test-wallet.ts new file mode 100644 index 000000000..8605aa765 --- /dev/null +++ b/src.ts/_tests/test-wallet.ts @@ -0,0 +1,83 @@ +import assert from "assert"; + +import { loadTests } from "./utils.js"; + +import type { + TestCaseAccount, TestCaseTypedData, TestCaseTransaction +} from "./types.js"; + + +import { Wallet } from "../index.js"; + + +describe("Test Private Key Wallet", function() { + const tests = loadTests("accounts"); + + tests.forEach(({ name, privateKey, address }) => { + it(`creates wallet: ${ name }`, function() { + const wallet = new Wallet(privateKey); + assert.equal(wallet.privateKey, privateKey); + assert.equal(wallet.address, address); + }); + }); +}); + +describe("Test Transaction Signing", function() { + const tests = loadTests("transactions"); + for (const test of tests) { + it(`tests signing a legacy transaction: ${ test.name }`, async function() { + const wallet = new Wallet(test.privateKey); + const txData = Object.assign({ }, test.transaction, { type: 0, accessList: undefined, maxFeePerGas: undefined, maxPriorityFeePerGas: undefined }); + + // Use the testcase sans the chainId for a legacy test + if (txData.chainId != null && parseInt(txData.chainId) != 0) { txData.chainId = "0x00"; } + + const signed = await wallet.signTransaction(txData); + assert.equal(signed, test.signedLegacy, "signedLegacy"); + }); + } + + for (const test of tests) { + if (!test.signedEip155) { continue; } + it(`tests signing an EIP-155 transaction: ${ test.name }`, async function() { + const wallet = new Wallet(test.privateKey); + const txData = Object.assign({ }, test.transaction, { type: 0, accessList: undefined, maxFeePerGas: undefined, maxPriorityFeePerGas: undefined }); + const signed = await wallet.signTransaction(txData); + assert.equal(signed, test.signedEip155, "signedEip155"); + }); + } + + for (const test of tests) { + it(`tests signing a Berlin transaction: ${ test.name }`, async function() { + const wallet = new Wallet(test.privateKey); + const txData = Object.assign({ }, test.transaction, { type: 1, maxFeePerGas: undefined, maxPriorityFeePerGas: undefined }); + const signed = await wallet.signTransaction(txData); + assert.equal(signed, test.signedBerlin, "signedBerlin"); + }); + } + + for (const test of tests) { + it(`tests signing a London transaction: ${ test.name }`, async function() { + const wallet = new Wallet(test.privateKey); + const txData = Object.assign({ }, test.transaction, { type: 2 }); + const signed = await wallet.signTransaction(txData); + assert.equal(signed, test.signedLondon, "signedLondon"); + }); + } +}); + +describe("Test Message Signing (EIP-191)", function() { +}); + +describe("Test Typed-Data Signing (EIP-712)", function() { + const tests = loadTests("typed-data"); + for (const test of tests) { + const { privateKey, signature } = test; + if (privateKey == null || signature == null) { continue; } + it(`tests signing typed-data: ${ test.name }`, async function() { + const wallet = new Wallet(privateKey); + const sig = await wallet.signTypedData(test.domain, test.types, test.data); + assert.equal(sig, signature, "signature"); + }); + } +}); diff --git a/src.ts/_tests/test-wordlists.ts b/src.ts/_tests/test-wordlists.ts new file mode 100644 index 000000000..f016f1d40 --- /dev/null +++ b/src.ts/_tests/test-wordlists.ts @@ -0,0 +1,79 @@ +import assert from 'assert'; + +import { wordlists } from "../index.js"; + +import { loadTests } from "./utils.js"; + +import type { TestCaseWordlist } from "./types.js"; + + +describe('Check Wordlists', function() { + const tests = loadTests("wordlists"); + + tests.forEach((test) => { + let wordlist = wordlists[test.locale]; + if (wordlist == null) { return; } + + it(`matches wordlists: ${ test.locale }`, function() { + const words = test.content.split('\n'); + + let check = ""; + for (let i = 0; i < 2048; i++) { + let word = wordlist.getWord(i); + check += (word + "\n"); + assert.equal(word, words[i]); + assert.equal(wordlist.getWordIndex(word), i); + } + + assert.equal(check, test.content); + }); + }); + + tests.forEach((test) => { + let wordlist = wordlists[test.locale]; + if (wordlist == null) { return; } + + it (`splitting and joining are equivalent: ${ test.locale }`, function() { + const words: Array = [ ]; + for (let i = 0; i < 12; i++) { + words.push(wordlist.getWord(i)); + } + + const phrase = wordlist.join(words); + + const words2 = wordlist.split(phrase); + const phrase2 = wordlist.join(words2); + + assert.deepStrictEqual(words2, words, "split words"); + assert.deepStrictEqual(phrase2, phrase, "re-joined words"); + }); + }); + + tests.forEach((test) => { + let wordlist = wordlists[test.locale]; + if (wordlist == null) { return; } + + it(`handles out-of-range values: ${ test.locale }`, function() { + assert.equal(wordlist.getWordIndex("foobar"), -1); + + assert.throws(() => { + wordlist.getWord(-1); + }, (error: any) => { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^invalid word index/) && + error.argument === "index" && + error.value === -1); + }); + + assert.throws(() => { + wordlist.getWord(2048); + }, (error: any) => { + return (error.code === "INVALID_ARGUMENT" && + error.message.match(/^invalid word index/) && + error.argument === "index" && + error.value === 2048); + }); + }); + + }); +}); diff --git a/src.ts/_tests/types.ts b/src.ts/_tests/types.ts new file mode 100644 index 000000000..617cb4037 --- /dev/null +++ b/src.ts/_tests/types.ts @@ -0,0 +1,245 @@ + + +export type TestCaseAbiVerbose = { + type: "address" | "hexstring" | "number" | "string", + value: string +} | { + type: "boolean", + value: boolean +} | { + type: "array", + value: Array +} | { + type: "object", + value: Array +} + +export interface TestCaseAbi { + name: string; + type: string; + value: any; + verbose: TestCaseAbiVerbose; + bytecode: string; + encoded: string; +} + +///////////////////////////// +// address + +export interface TestCaseAccount { + name: string; + privateKey: string; + address: string; + icap: string; +} + +export type TestCaseCreate = { + sender: string; + creates: Array<{ + name: string, + nonce: number, + address: string + }>; +}; + +export type TestCaseCreate2 = { + sender: string; + creates: Array<{ + name: string, + salt: string; + initCode: string + initCodeHash: string + address: string; + }>; +}; + + +///////////////////////////// +// crypto + +export interface TestCaseHash { + name: string; + data: string; + sha256: string; + sha512: string; + ripemd160: string; + keccak256: string; +} + +export interface TestCasePbkdf { + name: string; + password: string; + salt: string; + dkLen: number; + pbkdf2: { + iterations: number; + algorithm: "sha256" | "sha512"; + key: string; + }, + scrypt: { + N: number; + r: number; + p: number; + key: string; + } +} + +export interface TestCaseHmac { + name: string; + data: string; + key: string; + algorithm: "sha256" | "sha512"; + hmac: string; +} + +///////////////////////////// +// hash + +export interface TestCaseHash { + name: string; + data: string; + sha256: string; + sha512: string; + ripemd160: string; + keccak256: string; +} + +export interface TestCaseNamehash { + name: string; + ensName: string; + namehash: string; +} + +export interface TestCaseTypedDataDomain { + name?: string; + version?: string; + chainId?: number; + verifyingContract?: string; + salt?: string; +} + +export interface TestCaseTypedDataType { + name: string; + type: string; +} + +export interface TestCaseTypedData { + name: string; + + domain: TestCaseTypedDataDomain; + primaryType: string; + types: Record> + data: any; + + encoded: string; + digest: string; + + privateKey?: string; + signature?: string; +} + + +///////////////////////////// +// rlp + +export type NestedHexString = string | Array; + +export interface TestCaseRlp { + name: string; + encoded: string; + decoded: NestedHexString; +} + + +///////////////////////////// +// transaction + +export interface TestCaseTransactionTx { + to?: string; + nonce?: number; + gasLimit?: string; + + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + + data?: string; + value?: string; + + accessList?: Array<{ address: string, storageKeys: Array }>; + + chainId?: string; +} + +export interface TestCaseTransactionSig { + r: string; + s: string; + v: string; +} + +export interface TestCaseTransaction { + name: string; + transaction: TestCaseTransactionTx; + privateKey: string; + + unsignedLegacy: string; + signedLegacy: string; + unsignedEip155: string; + signedEip155: string; + unsignedBerlin: string; + signedBerlin: string; + unsignedLondon: string; + signedLondon: string; + + signatureLegacy: TestCaseTransactionSig; + signatureEip155: TestCaseTransactionSig; + signatureBerlin: TestCaseTransactionSig; + signatureLondon: TestCaseTransactionSig; +} + + +///////////////////////////// +// wallet + +export interface TestCaseMnemonicNode { + path: string, + chainCode: string; + depth: number; + index: number; + parentFingerprint: string; + fingerprint: string; + publicKey: string; + privateKey: string; + xpriv: string; + xpub: string; +} + +export interface TestCaseMnemonic { + name: string; + phrase: string; + phraseHash: string; + password: string; + locale: string; + entropy: string; + seed: string; + nodes: Array; +}; + +export interface TestCaseWallet { + name: string; + filename: string, + type: string; + address: string; + password: string; + content: string; +} + +///////////////////////////// +// wordlists + +export interface TestCaseWordlist { + name: string; + filename: string, + locale: string; + content: string; +} diff --git a/src.ts/_tests/utils.ts b/src.ts/_tests/utils.ts new file mode 100644 index 000000000..6aeef552b --- /dev/null +++ b/src.ts/_tests/utils.ts @@ -0,0 +1,32 @@ + +import fs from "fs" +import path from "path"; +import zlib from 'zlib'; + +// Find the package root (based on the nyc output/ folder) +const root = (function() { + let root = process.cwd(); + + while (true) { + if (fs.existsSync(path.join(root, "output"))) { return root; } + const parent = path.join(root, ".."); + if (parent === root) { break; } + root = parent; + } + + throw new Error("could not find root"); +})(); + +// Load the tests +export function loadTests(tag: string): Array { + const filename = path.resolve(root, "testcases", tag + ".json.gz"); + return JSON.parse(zlib.gunzipSync(fs.readFileSync(filename)).toString()); +} + +export function log(context: any, text: string): void { + if (context && context.test && typeof(context.test._ethersLog) === "function") { + context.test._ethersLog(text); + } else { + console.log(text); + } +} diff --git a/src.ts/_version.ts b/src.ts/_version.ts new file mode 100644 index 000000000..f79f7e742 --- /dev/null +++ b/src.ts/_version.ts @@ -0,0 +1 @@ +export const version = "6.0.0-beta-exports.0"; \ No newline at end of file diff --git a/src.ts/abi/abi-coder.ts b/src.ts/abi/abi-coder.ts new file mode 100644 index 000000000..c9115c54b --- /dev/null +++ b/src.ts/abi/abi-coder.ts @@ -0,0 +1,95 @@ +// See: https://github.com/ethereum/wiki/wiki/Ethereum-Contract-ABI + +import { logger } from "../utils/logger.js"; + +import { Coder, Reader, Result, Writer } from "./coders/abstract-coder.js"; +import { AddressCoder } from "./coders/address.js"; +import { ArrayCoder } from "./coders/array.js"; +import { BooleanCoder } from "./coders/boolean.js"; +import { BytesCoder } from "./coders/bytes.js"; +import { FixedBytesCoder } from "./coders/fixed-bytes.js"; +import { NullCoder } from "./coders/null.js"; +import { NumberCoder } from "./coders/number.js"; +import { StringCoder } from "./coders/string.js"; +import { TupleCoder } from "./coders/tuple.js"; +import { ParamType } from "./fragments.js"; + +import type { BytesLike } from "../utils/index.js"; + + +const paramTypeBytes = new RegExp(/^bytes([0-9]*)$/); +const paramTypeNumber = new RegExp(/^(u?int)([0-9]*)$/); + +export class AbiCoder { + + #getCoder(param: ParamType): Coder { + if (param.isArray()) { + return new ArrayCoder(this.#getCoder(param.arrayChildren), param.arrayLength, param.name); + } + + if (param.isTuple()) { + return new TupleCoder(param.components.map((c) => this.#getCoder(c)), param.name); + } + + switch (param.baseType) { + case "address": + return new AddressCoder(param.name); + case "bool": + return new BooleanCoder(param.name); + case "string": + return new StringCoder(param.name); + case "bytes": + return new BytesCoder(param.name); + case "": + return new NullCoder(param.name); + } + + // u?int[0-9]* + let match = param.type.match(paramTypeNumber); + if (match) { + let size = parseInt(match[2] || "256"); + if (size === 0 || size > 256 || (size % 8) !== 0) { + logger.throwArgumentError("invalid " + match[1] + " bit length", "param", param); + } + return new NumberCoder(size / 8, (match[1] === "int"), param.name); + } + + // bytes[0-9]+ + match = param.type.match(paramTypeBytes); + if (match) { + let size = parseInt(match[1]); + if (size === 0 || size > 32) { + logger.throwArgumentError("invalid bytes length", "param", param); + } + return new FixedBytesCoder(size, param.name); + } + + return logger.throwArgumentError("invalid type", "type", param.type); + } + + getDefaultValue(types: ReadonlyArray): Result { + const coders: Array = types.map((type) => this.#getCoder(ParamType.from(type))); + const coder = new TupleCoder(coders, "_"); + return coder.defaultValue(); + } + + encode(types: ReadonlyArray, values: ReadonlyArray): string { + logger.assertArgumentCount(values.length, types.length, "types/values length mismatch"); + + const coders = types.map((type) => this.#getCoder(ParamType.from(type))); + const coder = (new TupleCoder(coders, "_")); + + const writer = new Writer(); + coder.encode(writer, values); + return writer.data; + } + + decode(types: ReadonlyArray, data: BytesLike, loose?: boolean): Result { + const coders: Array = types.map((type) => this.#getCoder(ParamType.from(type))); + const coder = new TupleCoder(coders, "_"); + return coder.decode(new Reader(data, loose)); + } +} + + +export const defaultAbiCoder: AbiCoder = new AbiCoder(); diff --git a/src.ts/abi/bytes32.ts b/src.ts/abi/bytes32.ts new file mode 100644 index 000000000..4dc767bb3 --- /dev/null +++ b/src.ts/abi/bytes32.ts @@ -0,0 +1,37 @@ + +import { zeroPadBytes } from "../utils/data.js"; + +import { logger } from "../utils/logger.js"; + +import { toUtf8Bytes, toUtf8String } from "../utils/utf8.js"; + +import type { BytesLike } from "../utils/index.js"; + + +export function formatBytes32String(text: string): string { + + // Get the bytes + const bytes = toUtf8Bytes(text); + + // Check we have room for null-termination + if (bytes.length > 31) { throw new Error("bytes32 string must be less than 32 bytes"); } + + // Zero-pad (implicitly null-terminates) + return zeroPadBytes(bytes, 32); +} + +export function parseBytes32String(_bytes: BytesLike): string { + const data = logger.getBytes(_bytes, "bytes"); + + // Must be 32 bytes with a null-termination + if (data.length !== 32) { throw new Error("invalid bytes32 - not 32 bytes long"); } + if (data[31] !== 0) { throw new Error("invalid bytes32 string - no null terminator"); } + + // Find the null termination + let length = 31; + while (data[length - 1] === 0) { length--; } + + // Determine the string value + return toUtf8String(data.slice(0, length)); +} + diff --git a/src.ts/abi/coders/abstract-coder.ts b/src.ts/abi/coders/abstract-coder.ts new file mode 100644 index 000000000..bd3a28b8d --- /dev/null +++ b/src.ts/abi/coders/abstract-coder.ts @@ -0,0 +1,318 @@ + +import { toArray, toBigInt, toNumber } from "../../utils/maths.js"; +import { concat, hexlify } from "../../utils/data.js"; +import { defineProperties } from "../../utils/properties.js"; + +import { logger } from "../../utils/logger.js"; + +import type { BigNumberish, BytesLike } from "../../utils/index.js"; + +export const WordSize = 32; +const Padding = new Uint8Array(WordSize); + +// Properties used to immediate pass through to the underlying object +// - `then` is used to detect if an object is a Promise for await +const passProperties = [ "then" ]; + +const _guard = { }; + +export class Result extends Array { + #indices: Map>; + + [ K: string | number ]: any + + constructor(guard: any, items: Array, keys?: Array) { + logger.assertPrivate(guard, _guard, "Result"); + super(...items); + + // Name lookup table + this.#indices = new Map(); + + if (keys) { + keys.forEach((key, index) => { + if (key == null) { return; } + if (this.#indices.has(key)) { + (>(this.#indices.get(key))).push(index); + } else { + this.#indices.set(key, [ index ]); + } + }); + } + Object.freeze(this); + + return new Proxy(this, { + get: (target, prop, receiver) => { + if (typeof(prop) === "string") { + if (prop.match(/^[0-9]+$/)) { + const index = logger.getNumber(prop, "%index"); + if (index < 0 || index >= this.length) { + throw new RangeError("out of result range"); + } + + const item = target[index]; + if (item instanceof Error) { + this.#throwError(`index ${ index }`, item); + } + return item; + } + + // Pass important checks (like `then` for Promise) through + if (prop in target || passProperties.indexOf(prop) >= 0) { + return Reflect.get(target, prop, receiver); + } + + // Something that could be a result keyword value + if (!(prop in target)) { + return target.getValue(prop); + } + } + + return Reflect.get(target, prop, receiver); + } + }); + } + + /* + toJSON(): any { + if (this.#indices.length === this.length) { + const result: Record = { }; + for (const key of this.#indices.keys()) { + result[key] = ths.getValue(key); + } + return result; + } + return this; + } + */ + + slice(start?: number | undefined, end?: number | undefined): Array { + if (start == null) { start = 0; } + if (end == null) { end = this.length; } + + const result = [ ]; + for (let i = start; i < end; i++) { + let value: any; + try { + value = this[i]; + } catch (error: any) { + value = error.error; + } + result.push(value); + } + return result; + } + + #throwError(name: string, error: Error): never { + const wrapped = new Error(`deferred error during ABI decoding triggered accessing ${ name }`); + (wrapped).error = error; + throw wrapped; + } + + getValue(name: string): any { + const index = this.#indices.get(name); + if (index != null && index.length === 1) { + const item = this[index[0]]; + if (item instanceof Error) { + this.#throwError(`property ${ JSON.stringify(name) }`, item); + } + return item; + } + + throw new Error(`no named parameter: ${ JSON.stringify(name) }`); + } + + static fromItems(items: Array, keys?: Array) { + return new Result(_guard, items, keys); + } +} + +export function checkResultErrors(result: Result): Array<{ path: Array, error: Error }> { + // Find the first error (if any) + const errors: Array<{ path: Array, error: Error }> = [ ]; + + const checkErrors = function(path: Array, object: any): void { + if (!Array.isArray(object)) { return; } + for (let key in object) { + const childPath = path.slice(); + childPath.push(key); + + try { + checkErrors(childPath, object[key]); + } catch (error: any) { + errors.push({ path: childPath, error: error }); + } + } + } + checkErrors([ ], result); + + return errors; + +} + +function getValue(value: BigNumberish): Uint8Array { + let bytes = toArray(value); + + if (bytes.length > WordSize) { + logger.throwError("value out-of-bounds", "BUFFER_OVERRUN", { + buffer: bytes, + length: WordSize, + offset: bytes.length + }); + } + + if (bytes.length !== WordSize) { + bytes = logger.getBytesCopy(concat([ Padding.slice(bytes.length % WordSize), bytes ])); + } + + return bytes; +} + + +export abstract class Coder { + + // The coder name: + // - address, uint256, tuple, array, etc. + readonly name!: string; + + // The fully expanded type, including composite types: + // - address, uint256, tuple(address,bytes), uint256[3][4][], etc. + readonly type!: string; + + // The localName bound in the signature, in this example it is "baz": + // - tuple(address foo, uint bar) baz + readonly localName!: string; + + // Whether this type is dynamic: + // - Dynamic: bytes, string, address[], tuple(boolean[]), etc. + // - Not Dynamic: address, uint256, boolean[3], tuple(address, uint8) + readonly dynamic!: boolean; + + constructor(name: string, type: string, localName: string, dynamic: boolean) { + defineProperties(this, { name, type, localName, dynamic }, { + name: "string", type: "string", localName: "string", dynamic: "boolean" + }); + } + + _throwError(message: string, value: any): never { + return logger.throwArgumentError(message, this.localName, value); + } + + abstract encode(writer: Writer, value: any): number; + abstract decode(reader: Reader): any; + + abstract defaultValue(): any; +} + +export class Writer { + // An array of WordSize lengthed objects to concatenation + #data: Array; + #dataLength: number; + + constructor() { + this.#data = [ ]; + this.#dataLength = 0; + } + + get data(): string { + return concat(this.#data); + } + get length(): number { return this.#dataLength; } + + #writeData(data: Uint8Array): number { + this.#data.push(data); + this.#dataLength += data.length; + return data.length; + } + + appendWriter(writer: Writer): number { + return this.#writeData(logger.getBytesCopy(writer.data)); + } + + // Arrayish item; pad on the right to *nearest* WordSize + writeBytes(value: BytesLike): number { + let bytes = logger.getBytesCopy(value); + const paddingOffset = bytes.length % WordSize; + if (paddingOffset) { + bytes = logger.getBytesCopy(concat([ bytes, Padding.slice(paddingOffset) ])) + } + return this.#writeData(bytes); + } + + // Numeric item; pad on the left *to* WordSize + writeValue(value: BigNumberish): number { + return this.#writeData(getValue(value)); + } + + // Inserts a numeric place-holder, returning a callback that can + // be used to asjust the value later + writeUpdatableValue(): (value: BigNumberish) => void { + const offset = this.#data.length; + this.#data.push(Padding); + this.#dataLength += WordSize; + return (value: BigNumberish) => { + this.#data[offset] = getValue(value); + }; + } +} + +export class Reader { + // Allows incomplete unpadded data to be read; otherwise an error + // is raised if attempting to overrun the buffer. This is required + // to deal with an old Solidity bug, in which event data for + // external (not public thoguh) was tightly packed. + readonly allowLoose!: boolean; + + readonly #data: Uint8Array; + #offset: number; + + constructor(data: BytesLike, allowLoose?: boolean) { + defineProperties(this, { allowLoose: !!allowLoose }); + + this.#data = logger.getBytesCopy(data); + + this.#offset = 0; + } + + get data(): string { return hexlify(this.#data); } + get dataLength(): number { return this.#data.length; } + get consumed(): number { return this.#offset; } + get bytes(): Uint8Array { return new Uint8Array(this.#data); } + + #peekBytes(offset: number, length: number, loose?: boolean): Uint8Array { + let alignedLength = Math.ceil(length / WordSize) * WordSize; + if (this.#offset + alignedLength > this.#data.length) { + if (this.allowLoose && loose && this.#offset + length <= this.#data.length) { + alignedLength = length; + } else { + logger.throwError("data out-of-bounds", "BUFFER_OVERRUN", { + buffer: logger.getBytesCopy(this.#data), + length: this.#data.length, + offset: this.#offset + alignedLength + }); + } + } + return this.#data.slice(this.#offset, this.#offset + alignedLength) + } + + // Create a sub-reader with the same underlying data, but offset + subReader(offset: number): Reader { + return new Reader(this.#data.slice(this.#offset + offset), this.allowLoose); + } + + // Read bytes + readBytes(length: number, loose?: boolean): Uint8Array { + let bytes = this.#peekBytes(0, length, !!loose); + this.#offset += bytes.length; + // @TODO: Make sure the length..end bytes are all 0? + return bytes.slice(0, length); + } + + // Read a numeric values + readValue(): bigint { + return toBigInt(this.readBytes(WordSize)); + } + + readIndex(): number { + return toNumber(this.readBytes(WordSize)); + } +} diff --git a/src.ts/abi/coders/address.ts b/src.ts/abi/coders/address.ts new file mode 100644 index 000000000..2a78380ef --- /dev/null +++ b/src.ts/abi/coders/address.ts @@ -0,0 +1,33 @@ +import { getAddress } from "../../address/index.js"; +import { toHex } from "../../utils/maths.js"; + +import { Typed } from "../typed.js"; +import { Coder } from "./abstract-coder.js"; + +import type { Reader, Writer } from "./abstract-coder.js"; + + +export class AddressCoder extends Coder { + + constructor(localName: string) { + super("address", "address", localName, false); + } + + defaultValue(): string { + return "0x0000000000000000000000000000000000000000"; + } + + encode(writer: Writer, _value: string | Typed): number { + let value = Typed.dereference(_value, "string"); + try { + value = getAddress(value); + } catch (error: any) { + return this._throwError(error.message, _value); + } + return writer.writeValue(value); + } + + decode(reader: Reader): any { + return getAddress(toHex(reader.readValue(), 20)); + } +} diff --git a/src.ts/abi/coders/anonymous.ts b/src.ts/abi/coders/anonymous.ts new file mode 100644 index 000000000..3b06e881f --- /dev/null +++ b/src.ts/abi/coders/anonymous.ts @@ -0,0 +1,25 @@ +import { Coder } from "./abstract-coder.js"; + +import type { Reader, Writer } from "./abstract-coder.js"; + +// Clones the functionality of an existing Coder, but without a localName +export class AnonymousCoder extends Coder { + private coder: Coder; + + constructor(coder: Coder) { + super(coder.name, coder.type, "_", coder.dynamic); + this.coder = coder; + } + + defaultValue(): any { + return this.coder.defaultValue(); + } + + encode(writer: Writer, value: any): number { + return this.coder.encode(writer, value); + } + + decode(reader: Reader): any { + return this.coder.decode(reader); + } +} diff --git a/src.ts/abi/coders/array.ts b/src.ts/abi/coders/array.ts new file mode 100644 index 000000000..51c749027 --- /dev/null +++ b/src.ts/abi/coders/array.ts @@ -0,0 +1,208 @@ +import { defineProperties } from "../../utils/properties.js"; +import { isError } from "../../utils/errors.js"; +import { logger } from "../../utils/logger.js"; + +import { Typed } from "../typed.js"; +import { Coder, Result, WordSize, Writer } from "./abstract-coder.js"; +import { AnonymousCoder } from "./anonymous.js"; + +import type { Reader } from "./abstract-coder.js"; + + +export function pack(writer: Writer, coders: ReadonlyArray, values: Array | { [ name: string ]: any }): number { + let arrayValues: Array = [ ]; + + if (Array.isArray(values)) { + arrayValues = values; + + } else if (values && typeof(values) === "object") { + let unique: { [ name: string ]: boolean } = { }; + + arrayValues = coders.map((coder) => { + const name = coder.localName; + if (!name) { + logger.throwError("cannot encode object for signature with missing names", "INVALID_ARGUMENT", { + argument: "values", + info: { coder }, + value: values + }); + } + + if (unique[name]) { + logger.throwError("cannot encode object for signature with duplicate names", "INVALID_ARGUMENT", { + argument: "values", + info: { coder }, + value: values + }); + } + + unique[name] = true; + + return values[name]; + }); + + } else { + logger.throwArgumentError("invalid tuple value", "tuple", values); + } + + if (coders.length !== arrayValues.length) { + logger.throwArgumentError("types/value length mismatch", "tuple", values); + } + + let staticWriter = new Writer(); + let dynamicWriter = new Writer(); + + let updateFuncs: Array<(baseOffset: number) => void> = []; + coders.forEach((coder, index) => { + let value = arrayValues[index]; + + if (coder.dynamic) { + // Get current dynamic offset (for the future pointer) + let dynamicOffset = dynamicWriter.length; + + // Encode the dynamic value into the dynamicWriter + coder.encode(dynamicWriter, value); + + // Prepare to populate the correct offset once we are done + let updateFunc = staticWriter.writeUpdatableValue(); + updateFuncs.push((baseOffset: number) => { + updateFunc(baseOffset + dynamicOffset); + }); + + } else { + coder.encode(staticWriter, value); + } + }); + + // Backfill all the dynamic offsets, now that we know the static length + updateFuncs.forEach((func) => { func(staticWriter.length); }); + + let length = writer.appendWriter(staticWriter); + length += writer.appendWriter(dynamicWriter); + return length; +} + +export function unpack(reader: Reader, coders: ReadonlyArray): Result { + let values: Array = []; + let keys: Array = [ ]; + + // A reader anchored to this base + let baseReader = reader.subReader(0); + + coders.forEach((coder) => { + let value: any = null; + + if (coder.dynamic) { + let offset = reader.readIndex(); + let offsetReader = baseReader.subReader(offset); + try { + value = coder.decode(offsetReader); + } catch (error: any) { + // Cannot recover from this + if (isError(error, "BUFFER_OVERRUN")) { + throw error; + } + + value = error; + value.baseType = coder.name; + value.name = coder.localName; + value.type = coder.type; + } + + } else { + try { + value = coder.decode(reader); + } catch (error: any) { + // Cannot recover from this + if (isError(error, "BUFFER_OVERRUN")) { + throw error; + } + + value = error; + value.baseType = coder.name; + value.name = coder.localName; + value.type = coder.type; + } + } + + if (value == undefined) { + throw new Error("investigate"); + } + + values.push(value); + keys.push(coder.localName || null); + }); + + return Result.fromItems(values, keys); +} + + +export class ArrayCoder extends Coder { + readonly coder!: Coder; + readonly length!: number; + + constructor(coder: Coder, length: number, localName: string) { + const type = (coder.type + "[" + (length >= 0 ? length: "") + "]"); + const dynamic = (length === -1 || coder.dynamic); + super("array", type, localName, dynamic); + defineProperties(this, { coder, length }); + } + + defaultValue(): Array { + // Verifies the child coder is valid (even if the array is dynamic or 0-length) + const defaultChild = this.coder.defaultValue(); + + const result: Array = []; + for (let i = 0; i < this.length; i++) { + result.push(defaultChild); + } + return result; + } + + encode(writer: Writer, _value: Array | Typed): number { + const value = Typed.dereference(_value, "array"); + + if (!Array.isArray(value)) { + this._throwError("expected array value", value); + } + + let count = this.length; + + if (count === -1) { + count = value.length; + writer.writeValue(value.length); + } + + logger.assertArgumentCount(value.length, count, "coder array" + (this.localName? (" "+ this.localName): "")); + + let coders = []; + for (let i = 0; i < value.length; i++) { coders.push(this.coder); } + + return pack(writer, coders, value); + } + + decode(reader: Reader): any { + let count = this.length; + if (count === -1) { + count = reader.readIndex(); + + // Check that there is *roughly* enough data to ensure + // stray random data is not being read as a length. Each + // slot requires at least 32 bytes for their value (or 32 + // bytes as a link to the data). This could use a much + // tighter bound, but we are erroring on the side of safety. + if (count * WordSize > reader.dataLength) { + logger.throwError("insufficient data length", "BUFFER_OVERRUN", { + buffer: reader.bytes, + offset: count * WordSize, + length: reader.dataLength + }); + } + } + let coders = []; + for (let i = 0; i < count; i++) { coders.push(new AnonymousCoder(this.coder)); } + + return unpack(reader, coders); + } +} + diff --git a/src.ts/abi/coders/boolean.ts b/src.ts/abi/coders/boolean.ts new file mode 100644 index 000000000..5dbf82edd --- /dev/null +++ b/src.ts/abi/coders/boolean.ts @@ -0,0 +1,25 @@ +import { Typed } from "../typed.js"; +import { Coder } from "./abstract-coder.js"; + +import type { Reader, Writer } from "./abstract-coder.js"; + + +export class BooleanCoder extends Coder { + + constructor(localName: string) { + super("bool", "bool", localName, false); + } + + defaultValue(): boolean { + return false; + } + + encode(writer: Writer, _value: boolean | Typed): number { + const value = Typed.dereference(_value, "bool"); + return writer.writeValue(value ? 1: 0); + } + + decode(reader: Reader): any { + return !!reader.readValue(); + } +} diff --git a/src.ts/abi/coders/bytes.ts b/src.ts/abi/coders/bytes.ts new file mode 100644 index 000000000..f5b4338ad --- /dev/null +++ b/src.ts/abi/coders/bytes.ts @@ -0,0 +1,38 @@ +import { logger } from "../../utils/logger.js"; +import { hexlify } from "../../utils/data.js"; + +import { Coder } from "./abstract-coder.js"; + +import type { Reader, Writer } from "./abstract-coder.js"; + + +export class DynamicBytesCoder extends Coder { + constructor(type: string, localName: string) { + super(type, type, localName, true); + } + + defaultValue(): string { + return "0x"; + } + + encode(writer: Writer, value: any): number { + value = logger.getBytesCopy(value); + let length = writer.writeValue(value.length); + length += writer.writeBytes(value); + return length; + } + + decode(reader: Reader): any { + return reader.readBytes(reader.readIndex(), true); + } +} + +export class BytesCoder extends DynamicBytesCoder { + constructor(localName: string) { + super("bytes", localName); + } + + decode(reader: Reader): any { + return hexlify(super.decode(reader)); + } +} diff --git a/src.ts/abi/coders/fixed-bytes.ts b/src.ts/abi/coders/fixed-bytes.ts new file mode 100644 index 000000000..466feddd1 --- /dev/null +++ b/src.ts/abi/coders/fixed-bytes.ts @@ -0,0 +1,36 @@ + +import { logger } from "../../utils/logger.js"; +import { hexlify } from "../../utils/data.js"; +import { defineProperties } from "../../utils/properties.js"; + +import { Typed } from "../typed.js"; +import { Coder } from "./abstract-coder.js"; + +import type { BytesLike } from "../../utils/index.js"; + +import type { Reader, Writer } from "./abstract-coder.js"; + + +export class FixedBytesCoder extends Coder { + readonly size!: number; + + constructor(size: number, localName: string) { + let name = "bytes" + String(size); + super(name, name, localName, false); + defineProperties(this, { size }, { size: "number" }); + } + + defaultValue(): string { + return ("0x0000000000000000000000000000000000000000000000000000000000000000").substring(0, 2 + this.size * 2); + } + + encode(writer: Writer, _value: BytesLike | Typed): number { + let data = logger.getBytesCopy(Typed.dereference(_value, this.type)); + if (data.length !== this.size) { this._throwError("incorrect data length", _value); } + return writer.writeBytes(data); + } + + decode(reader: Reader): any { + return hexlify(reader.readBytes(this.size)); + } +} diff --git a/src.ts/abi/coders/null.ts b/src.ts/abi/coders/null.ts new file mode 100644 index 000000000..7a2e796f8 --- /dev/null +++ b/src.ts/abi/coders/null.ts @@ -0,0 +1,25 @@ +import { Coder } from "./abstract-coder.js"; +import type { Reader, Writer } from "./abstract-coder.js"; + +const Empty = new Uint8Array([ ]); + +export class NullCoder extends Coder { + + constructor(localName: string) { + super("null", "", localName, false); + } + + defaultValue(): null { + return null; + } + + encode(writer: Writer, value: any): number { + if (value != null) { this._throwError("not null", value); } + return writer.writeBytes(Empty); + } + + decode(reader: Reader): any { + reader.readBytes(0); + return null; + } +} diff --git a/src.ts/abi/coders/number.ts b/src.ts/abi/coders/number.ts new file mode 100644 index 000000000..1851346dc --- /dev/null +++ b/src.ts/abi/coders/number.ts @@ -0,0 +1,65 @@ +import { fromTwos, mask, toTwos } from "../../utils/maths.js"; +import { defineProperties } from "../../utils/properties.js"; + +import { logger } from "../../utils/logger.js"; +import { Typed } from "../typed.js"; +import { Coder, WordSize } from "./abstract-coder.js"; + +import type { BigNumberish } from "../../utils/index.js"; + +import type { Reader, Writer } from "./abstract-coder.js"; + + +const BN_0 = BigInt(0); +const BN_1 = BigInt(1); +const BN_MAX_UINT256 = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + +export class NumberCoder extends Coder { + readonly size!: number; + readonly signed!: boolean; + + constructor(size: number, signed: boolean, localName: string) { + const name = ((signed ? "int": "uint") + (size * 8)); + super(name, name, localName, false); + + defineProperties(this, { size, signed }, { size: "number", signed: "boolean" }); + } + + defaultValue(): number { + return 0; + } + + encode(writer: Writer, _value: BigNumberish | Typed): number { + let value = logger.getBigInt(Typed.dereference(_value, this.type)); + + // Check bounds are safe for encoding + let maxUintValue = mask(BN_MAX_UINT256, WordSize * 8); + if (this.signed) { + let bounds = mask(maxUintValue, (this.size * 8) - 1); + if (value > bounds || value < -(bounds + BN_1)) { + this._throwError("value out-of-bounds", _value); + } + } else if (value < BN_0 || value > mask(maxUintValue, this.size * 8)) { + this._throwError("value out-of-bounds", _value); + } + + value = mask(toTwos(value, this.size * 8), this.size * 8); + + if (this.signed) { + value = toTwos(fromTwos(value, this.size * 8), 8 * WordSize); + } + + return writer.writeValue(value); + } + + decode(reader: Reader): any { + let value = mask(reader.readValue(), this.size * 8); + + if (this.signed) { + value = fromTwos(value, this.size * 8); + } + + return value; + } +} + diff --git a/src.ts/abi/coders/string.ts b/src.ts/abi/coders/string.ts new file mode 100644 index 000000000..7efeff097 --- /dev/null +++ b/src.ts/abi/coders/string.ts @@ -0,0 +1,26 @@ +import { toUtf8Bytes, toUtf8String } from "../../utils/utf8.js"; + +import { Typed } from "../typed.js"; +import { DynamicBytesCoder } from "./bytes.js"; + +import type { Reader, Writer } from "./abstract-coder.js"; + + +export class StringCoder extends DynamicBytesCoder { + + constructor(localName: string) { + super("string", localName); + } + + defaultValue(): string { + return ""; + } + + encode(writer: Writer, _value: string | Typed): number { + return super.encode(writer, toUtf8Bytes(Typed.dereference(_value, "string"))); + } + + decode(reader: Reader): any { + return toUtf8String(super.decode(reader)); + } +} diff --git a/src.ts/abi/coders/tuple.ts b/src.ts/abi/coders/tuple.ts new file mode 100644 index 000000000..4eb94f875 --- /dev/null +++ b/src.ts/abi/coders/tuple.ts @@ -0,0 +1,66 @@ +import { defineProperties } from "../../utils/properties.js"; + +import { Typed } from "../typed.js"; +import { Coder } from "./abstract-coder.js"; + +import { pack, unpack } from "./array.js"; + +import type { Reader, Writer } from "./abstract-coder.js"; + +export class TupleCoder extends Coder { + readonly coders!: ReadonlyArray; + + constructor(coders: Array, localName: string) { + let dynamic = false; + const types: Array = []; + coders.forEach((coder) => { + if (coder.dynamic) { dynamic = true; } + types.push(coder.type); + }); + const type = ("tuple(" + types.join(",") + ")"); + + super("tuple", type, localName, dynamic); + defineProperties(this, { coders: Object.freeze(coders.slice()) }); + } + + defaultValue(): any { + const values: any = [ ]; + this.coders.forEach((coder) => { + values.push(coder.defaultValue()); + }); + + // We only output named properties for uniquely named coders + const uniqueNames = this.coders.reduce((accum, coder) => { + const name = coder.localName; + if (name) { + if (!accum[name]) { accum[name] = 0; } + accum[name]++; + } + return accum; + }, <{ [ name: string ]: number }>{ }); + + // Add named values + this.coders.forEach((coder: Coder, index: number) => { + let name = coder.localName; + if (!name || uniqueNames[name] !== 1) { return; } + + if (name === "length") { name = "_length"; } + + if (values[name] != null) { return; } + + values[name] = values[index]; + }); + + return Object.freeze(values); + } + + encode(writer: Writer, _value: Array | { [ name: string ]: any } | Typed): number { + const value = Typed.dereference(_value, "tuple"); + return pack(writer, this.coders, value); + } + + decode(reader: Reader): any { + return unpack(reader, this.coders); + } +} + diff --git a/src.ts/abi/fragments.ts b/src.ts/abi/fragments.ts new file mode 100644 index 000000000..c632a3010 --- /dev/null +++ b/src.ts/abi/fragments.ts @@ -0,0 +1,1091 @@ +import { logger } from "../utils/logger.js"; +import { defineProperties } from "../utils/index.js"; + + +export interface JsonFragmentType { + readonly name?: string; + readonly indexed?: boolean; + readonly type?: string; + readonly internalType?: string; + readonly components?: ReadonlyArray; +} + +export interface JsonFragment { + readonly name?: string; + readonly type?: string; + + readonly anonymous?: boolean; + + readonly payable?: boolean; + readonly constant?: boolean; + readonly stateMutability?: string; + + readonly inputs?: ReadonlyArray; + readonly outputs?: ReadonlyArray; + + readonly gas?: string; +}; + +export enum FormatType { + // Bare formatting, as is needed for computing a sighash of an event or function + sighash = "sighash", + + // Human-Readable with Minimal spacing and without names (compact human-readable) + minimal = "minimal", + + // Human-Readable with nice spacing, including all names + full = "full", + + // JSON-format a la Solidity + json = "json" +}; + +// [ "a", "b" ] => { "a": 1, "b": 1 } +function setify(items: Array): ReadonlySet { + const result: Set = new Set(); + items.forEach((k) => result.add(k)); + return Object.freeze(result); +} + +// Visibility Keywords +const _kwVisib = "constant external internal payable private public pure view"; +const KwVisib = setify(_kwVisib.split(" ")); + +const _kwTypes = "constructor error event function struct"; +const KwTypes = setify(_kwTypes.split(" ")); + +const _kwModifiers = "calldata memory storage payable indexed"; +const KwModifiers = setify(_kwModifiers.split(" ")); + +const _kwOther = "tuple returns"; + +// All Keywords +const _keywords = [ _kwTypes, _kwModifiers, _kwOther, _kwVisib ].join(" "); +const Keywords = setify(_keywords.split(" ")); + +// Single character tokens +const SimpleTokens: Record = { + "(": "OPEN_PAREN", ")": "CLOSE_PAREN", + "[": "OPEN_BRACKET", "]": "CLOSE_BRACKET", + ",": "COMMA", "@": "AT" +}; + +// Parser regexes to consume the next token +const regexWhitespace = new RegExp("^(\\s*)"); +const regexNumber = new RegExp("^([0-9]+)"); +const regexIdentifier = new RegExp("^([a-zA-Z$_][a-zA-Z0-9$_]*)"); +const regexType = new RegExp("^(address|bool|bytes([0-9]*)|string|u?int([0-9]*))"); + + +export type Token = Readonly<{ + // Type of token (e.g. TYPE, KEYWORD, NUMBER, etc) + type: string; + + // Offset into the original source code + offset: number; + + // Actual text content of the token + text: string; + + // The parenthesis depth + depth: number; + + // If a parenthesis, the offset (in tokens) that balances it + match: number; + + // For parenthesis and commas, the offset (in tokens) to the + // previous/next parenthesis or comma in the list + linkBack: number; + linkNext: number; + + // If a BRACKET, the value inside + value: number; +}>; + +export class TokenString { + #offset: number; + #tokens: ReadonlyArray; + + get offset(): number { return this.#offset; } + get length(): number { return this.#tokens.length - this.#offset; } + + constructor(tokens: ReadonlyArray) { + this.#offset = 0; + this.#tokens = tokens.slice(); + } + + clone(): TokenString { return new TokenString(this.#tokens); } + reset(): void { this.#offset = 0; } + + #subTokenString(from: number = 0, to: number = 0): TokenString { + return new TokenString(this.#tokens.slice(from, to).map((t) => { + return Object.freeze(Object.assign({ }, t, { + match: (t.match - from), + linkBack: (t.linkBack - from), + linkNext: (t.linkNext - from), + })); + return t; + })); + } + + // Pops and returns the value of the next token, if it is a keyword in allowed; throws if out of tokens + popKeyword(allowed: ReadonlySet): string { + const top = this.peek(); + if (top.type !== "KEYWORD" || !allowed.has(top.text)) { throw new Error(`expected keyword ${ top.text }`); } + return this.pop().text; + } + + // Pops and returns the value of the next token if it is `type`; throws if out of tokens + popType(type: string): string { + if (this.peek().type !== type) { throw new Error(`expected ${ type }; got ${ JSON.stringify(this.peek()) }`); } + return this.pop().text; + } + + // Pops and returns a "(" TOKENS ")" + popParen(): TokenString { + const top = this.peek(); + if (top.type !== "OPEN_PAREN") { throw new Error("bad start"); } + const result = this.#subTokenString(this.#offset + 1, top.match + 1); + this.#offset = top.match + 1; + return result; + } + + // Pops and returns the items within "(" ITEM1 "," ITEM2 "," ... ")" + popParams(): Array { + const top = this.peek(); + + if (top.type !== "OPEN_PAREN") { throw new Error("bad start"); } + + const result: Array = [ ]; + + while(this.#offset < top.match - 1) { + const link = this.peek().linkNext; + result.push(this.#subTokenString(this.#offset + 1, link)); + this.#offset = link; + } + + this.#offset = top.match + 1; + + return result; + } + + // Returns the top Token, throwing if out of tokens + peek(): Token { + if (this.#offset >= this.#tokens.length) { + throw new Error("out-of-bounds"); + } + return this.#tokens[this.#offset]; + } + + // Returns the next value, if it is a keyword in `allowed` + peekKeyword(allowed: ReadonlySet): null | string { + const top = this.peekType("KEYWORD"); + return (top != null && allowed.has(top)) ? top: null; + } + + // Returns the value of the next token if it is `type` + peekType(type: string): null | string { + if (this.length === 0) { return null; } + const top = this.peek(); + return (top.type === type) ? top.text: null; + } + + // Returns the next token; throws if out of tokens + pop(): Token { + const result = this.peek(); + this.#offset++; + return result; + } + + toString(): string { + const tokens: Array = [ ]; + for (let i = this.#offset; i < this.#tokens.length; i++) { + const token = this.#tokens[i]; + tokens.push(`${ token.type }:${ token.text }`); + } + return `` + } +} + +type Writeable = { -readonly [P in keyof T]: T[P] }; + +export function lex(text: string): TokenString { + const tokens: Array = [ ]; + + const throwError = (message: string) => { + const token = (offset < text.length) ? JSON.stringify(text[offset]): "$EOI"; + throw new Error(`invalid token ${ token } at ${ offset }: ${ message }`); + }; + + let brackets: Array = [ ]; + let commas: Array = [ ]; + + let offset = 0; + while (offset < text.length) { + + // Strip off any leading whitespace + let cur = text.substring(offset); + let match = cur.match(regexWhitespace); + if (match) { + offset += match[1].length; + cur = text.substring(offset); + } + + const token = { depth: brackets.length, linkBack: -1, linkNext: -1, match: -1, type: "", text: "", offset, value: -1 }; + tokens.push(token); + + let type = (SimpleTokens[cur[0]] || ""); + if (type) { + token.type = type; + token.text = cur[0]; + offset++; + + if (type === "OPEN_PAREN") { + brackets.push(tokens.length - 1); + commas.push(tokens.length - 1); + + } else if (type == "CLOSE_PAREN") { + if (brackets.length === 0) { throwError("no matching open bracket"); } + + token.match = brackets.pop() as number; + (>(tokens[token.match])).match = tokens.length - 1; + token.depth--; + + token.linkBack = commas.pop() as number; + (>(tokens[token.linkBack])).linkNext = tokens.length - 1; + + } else if (type === "COMMA") { + token.linkBack = commas.pop() as number; + (>(tokens[token.linkBack])).linkNext = tokens.length - 1; + commas.push(tokens.length - 1); + + } else if (type === "OPEN_BRACKET") { + token.type = "BRACKET"; + + } else if (type === "CLOSE_BRACKET") { + // Remove the CLOSE_BRACKET + let suffix = (tokens.pop() as Token).text; + if (tokens.length > 0 && tokens[tokens.length - 1].type === "NUMBER") { + const value = (tokens.pop() as Token).text; + suffix = value + suffix; + (>(tokens[tokens.length - 1])).value = logger.getNumber(value); + } + if (tokens.length === 0 || tokens[tokens.length - 1].type !== "BRACKET") { + throw new Error("missing opening bracket"); + } + (>(tokens[tokens.length - 1])).text += suffix; + } + + continue; + } + + match = cur.match(regexIdentifier); + if (match) { + token.text = match[1]; + offset += token.text.length; + + if (Keywords.has(token.text)) { + token.type = "KEYWORD"; + continue; + } + + if (token.text.match(regexType)) { + token.type = "TYPE"; + continue; + } + + token.type = "ID"; + continue; + } + + match = cur.match(regexNumber); + if (match) { + token.text = match[1]; + token.type = "NUMBER"; + offset += token.text.length; + continue; + } + + throw new Error(`unexpected token ${ JSON.stringify(cur[0]) } at position ${ offset }`); + } + + return new TokenString(tokens.map((t) => Object.freeze(t))); +} + +// Check only one of `allowed` is in `set` +function allowSingle(set: ReadonlySet, allowed: ReadonlySet): void { + let included: Array = [ ]; + for (const key in allowed.keys()) { + if (set.has(key)) { included.push(key); } + } + if (included.length > 1) { throw new Error(`conflicting types: ${ included.join(", ") }`); } +} + +// Functions to process a Solidity Signature TokenString from left-to-right for... + +// ...the name with an optional type, returning the name +function consumeName(type: string, tokens: TokenString): string { + if (tokens.peekKeyword(KwTypes)) { + const keyword = tokens.pop().text; + if (keyword !== type) { + throw new Error(`expected ${ type }, got ${ keyword }`); + } + } + + return tokens.popType("ID"); +} + +// ...all keywords matching allowed, returning the keywords +function consumeKeywords(tokens: TokenString, allowed?: ReadonlySet): ReadonlySet { + const keywords: Set = new Set(); + while (true) { + const keyword = tokens.peekType("KEYWORD"); + + if (keyword == null || (allowed && !allowed.has(keyword))) { break; } + tokens.pop(); + + if (keywords.has(keyword)) { throw new Error(`duplicate keywords: ${ JSON.stringify(keyword) }`); } + keywords.add(keyword); + } + + return Object.freeze(keywords); +} + +// ...all visibility keywords, returning the coalesced mutability +function consumeMutability(tokens: TokenString): string { + let modifiers = consumeKeywords(tokens, KwVisib); + + // Detect conflicting modifiers + allowSingle(modifiers, setify("constant payable nonpayable".split(" "))); + allowSingle(modifiers, setify("pure view payable nonpayable".split(" "))); + + // Process mutability states + if (modifiers.has("view")) { return "view"; } + if (modifiers.has("pure")) { return "pure"; } + if (modifiers.has("payable")) { return "payable"; } + if (modifiers.has("nonpayable")) { return "nonpayable"; } + + // Process legacy `constant` last + if (modifiers.has("constant")) { return "view"; } + + return "nonpayable"; +} + +// ...a parameter list, returning the ParamType list +function consumeParams(tokens: TokenString, allowIndexed?: boolean): Array { + return tokens.popParams().map((t) => ParamType.fromTokens(t, allowIndexed)); +} + +// ...a gas limit, returning a BigNumber or null if none +function consumeGas(tokens: TokenString): null | bigint { + if (tokens.peekType("AT")) { + tokens.pop(); + if (tokens.peekType("NUMBER")) { + return logger.getBigInt(tokens.pop().text); + } + throw new Error("invalid gas"); + } + return null; +} + +function consumeEoi(tokens: TokenString): void { + if (tokens.length) { + throw new Error(`unexpected tokens: ${ tokens.toString() }`); + } +} + +const regexArrayType = new RegExp(/^(.*)\[([0-9]*)\]$/); + +function verifyBasicType(type: string): string { + const match = type.match(regexType); + if (!match) { + return logger.throwArgumentError("invalid type", "type", type); + } + if (type === "uint") { return "uint256"; } + if (type === "int") { return "int256"; } + + if (match[2]) { + // bytesXX + const length = parseInt(match[2]); + if (length === 0 || length > 32) { + logger.throwArgumentError("invalid bytes length", "type", type); + } + + } else if (match[3]) { + // intXX or uintXX + const size = parseInt(match[3] as string); + if (size === 0 || size > 256 || size % 8) { + logger.throwArgumentError("invalid numeric width", "type", type); + } + } + + return type; +} + +// Make the Fragment constructors effectively private +const _guard = { }; + +export interface ArrayParamType { //extends ParamType { + readonly arrayLength: number; + readonly arrayChildren: ParamType; +} + +export interface TupleParamType extends ParamType { + readonly components: ReadonlyArray; +} + +export interface IndexableParamType extends ParamType { + readonly indexed: boolean; +} + +export type FragmentWalkFunc = (type: string, value: any) => any; +export type FragmentWalkAsyncFunc = (type: string, value: any) => any | Promise; + +const internal = Symbol.for("_ethers_internal"); +const ParamTypeInternal = "_ParamTypeInternal"; + +export class ParamType { + + // The local name of the parameter (of "" if unbound) + readonly name!: string; + + // The fully qualified type (e.g. "address", "tuple(address)", "uint256[3][]" + readonly type!: string; + + // The base type (e.g. "address", "tuple", "array") + readonly baseType!: string; + + // Indexable Paramters ONLY (otherwise null) + readonly indexed!: null | boolean; + + // Tuples ONLY: (otherwise null) + // - sub-components + readonly components!: null | ReadonlyArray; + + // Arrays ONLY: (otherwise null) + // - length of the array (-1 for dynamic length) + // - child type + readonly arrayLength!: null | number; + readonly arrayChildren!: null | ParamType; + + + constructor(guard: any, name: string, type: string, baseType: string, indexed: null | boolean, components: null | ReadonlyArray, arrayLength: null | number, arrayChildren: null | ParamType) { + logger.assertPrivate(guard, _guard, "ParamType"); + Object.defineProperty(this, internal, { value: ParamTypeInternal }); + + if (components) { components = Object.freeze(components.slice()); } + + if (baseType === "array") { + if (arrayLength == null || arrayChildren == null) { + throw new Error(""); + } + } else if (arrayLength != null || arrayChildren != null) { + throw new Error(""); + } + + if (baseType === "tuple") { + if (components == null) { throw new Error(""); } + } else if (components != null) { + throw new Error(""); + } + + defineProperties(this, { + name, type, baseType, indexed, components, arrayLength, arrayChildren + }); + } + + // Format the parameter fragment + // - sighash: "(uint256,address)" + // - minimal: "tuple(uint256,address) indexed" + // - full: "tuple(uint256 foo, address bar) indexed baz" + format(format: FormatType = FormatType.sighash): string { + if (!FormatType[format]) { + logger.throwArgumentError("invalid format type", "format", format); + } + + if (format === FormatType.json) { + let result: any = { + type: ((this.baseType === "tuple") ? "tuple": this.type), + name: (this.name || undefined) + }; + if (typeof(this.indexed) === "boolean") { result.indexed = this.indexed; } + if (this.isTuple()) { + result.components = this.components.map((c) => JSON.parse(c.format(format))); + } + return JSON.stringify(result); + } + + let result = ""; + + // Array + if (this.isArray()) { + result += this.arrayChildren.format(format); + result += `[${ (this.arrayLength < 0 ? "": String(this.arrayLength)) }]`; + } else { + if (this.isTuple()) { + if (format !== FormatType.sighash) { result += this.type; } + result += "(" + this.components.map( + (comp) => comp.format(format) + ).join((format === FormatType.full) ? ", ": ",") + ")"; + } else { + result += this.type; + } + } + + if (format !== FormatType.sighash) { + if (this.indexed === true) { result += " indexed"; } + if (format === FormatType.full && this.name) { + result += " " + this.name; + } + } + + return result; + } + + static isArray(value: any): value is { arrayChildren: ParamType } { + return value && (value.baseType === "array") + } + + isArray(): this is (ParamType & ArrayParamType) { + return (this.baseType === "array") + } + + isTuple(): this is TupleParamType { + return (this.baseType === "tuple"); + } + + isIndexable(): this is IndexableParamType { + return (this.indexed != null); + } + + walk(value: any, process: FragmentWalkFunc): any { + if (this.isArray()) { + if (!Array.isArray(value)) { throw new Error("invlaid array value"); } + if (this.arrayLength !== -1 && value.length !== this.arrayLength) { + throw new Error("array is wrong length"); + } + return value.map((v) => ((this).arrayChildren.walk(v, process))); + } + + if (this.isTuple()) { + if (!Array.isArray(value)) { throw new Error("invlaid tuple value"); } + if (value.length !== this.components.length) { + throw new Error("array is wrong length"); + } + return value.map((v, i) => ((this).components[i].walk(v, process))); + } + + return process(this.type, value); + } + + #walkAsync(promises: Array>, value: any, process: FragmentWalkAsyncFunc, setValue: (value: any) => void): void { + + if (this.isArray()) { + if (!Array.isArray(value)) { throw new Error("invlaid array value"); } + if (this.arrayLength !== -1 && value.length !== this.arrayLength) { + throw new Error("array is wrong length"); + } + const childType = this.arrayChildren; + + const result = value.slice(); + result.forEach((value, index) => { + childType.#walkAsync(promises, value, process, (value: any) => { + result[index] = value; + }); + }); + setValue(result); + return; + } + + if (this.isTuple()) { + const components = this.components; + + // Convert the object into an array + let result: Array; + if (Array.isArray(value)) { + result = value.slice(); + + } else { + if (value == null || typeof(value) !== "object") { + throw new Error("invlaid tuple value"); + } + + result = components.map((param) => { + if (!param.name) { throw new Error("cannot use object value with unnamed components"); } + if (!(param.name in value)) { + throw new Error(`missing value for component ${ param.name }`); + } + return value[param.name]; + }); + } + if (value.length !== this.components.length) { + throw new Error("array is wrong length"); + } + + result.forEach((value, index) => { + components[index].#walkAsync(promises, value, process, (value: any) => { + result[index] = value; + }); + }); + setValue(result); + return; + } + + const result = process(this.type, value); + if (result.then) { + promises.push((async function() { setValue(await result); })()); + } else { + setValue(result); + } + } + + async walkAsync(value: any, process: (type: string, value: any) => any | Promise): Promise { + const promises: Array> = [ ]; + const result: [ any ] = [ value ]; + this.#walkAsync(promises, value, process, (value: any) => { + result[0] = value; + }); + if (promises.length) { await Promise.all(promises); } + return result[0]; + } + + static from(obj: any, allowIndexed?: boolean): ParamType { + if (ParamType.isParamType(obj)) { return obj; } + if (typeof(obj) === "string") { return ParamType.fromTokens(lex(obj), allowIndexed); } + if (obj instanceof TokenString) { return ParamType.fromTokens(obj, allowIndexed); } + + const name = obj.name; + if (name && (typeof(name) !== "string" || !name.match(regexIdentifier))) { + logger.throwArgumentError("invalid name", "obj.name", name); + } + + let indexed = obj.indexed; + if (indexed != null) { + if (!allowIndexed) { + logger.throwArgumentError("parameter cannot be indexed", "obj.indexed", obj.indexed); + } + indexed = !!indexed; + } + + let type = obj.type; + + let arrayMatch = type.match(regexArrayType); + if (arrayMatch) { + const arrayLength = arrayMatch[2]; + const arrayChildren = ParamType.from({ + type: arrayMatch[1], + components: obj.components + }); + + return new ParamType(_guard, name, type, "array", indexed, null, arrayLength, arrayChildren); + } + + if (type.substring(0, 5) === "tuple(" || type[0] === "(") { + const comps = (obj.components != null) ? obj.components.map((c: any) => ParamType.from(c)): null; + const tuple = new ParamType(_guard, name, type, "tuple", indexed, comps, null, null); + // @TODO: use lexer to validate and normalize type + return tuple; + } + + type = verifyBasicType(obj.type); + + return new ParamType(_guard, name, type, type, indexed, null, null, null); + } + + static fromObject(obj: any, allowIndexed?: boolean): ParamType { + throw new Error("@TODO"); + } + + static fromTokens(tokens: TokenString, allowIndexed?: boolean): ParamType { + let type = "", baseType = ""; + let comps: null | Array = null; + + if (consumeKeywords(tokens, setify([ "tuple" ])).has("tuple") || tokens.peekType("OPEN_PAREN")) { + // Tuple + baseType = "tuple"; + comps = tokens.popParams().map((t) => ParamType.from(t)); + type = `tuple(${ comps.map((c) => c.format()).join(",") })`; + } else { + // Normal + type = verifyBasicType(tokens.popType("TYPE")); + baseType = type; + } + + // Check for Array + let arrayChildren: null | ParamType = null; + let arrayLength: null | number = null; + + while (tokens.length && tokens.peekType("BRACKET")) { + const bracket = tokens.pop(); //arrays[i]; + arrayChildren = new ParamType(_guard, "", type, baseType, null, comps, arrayLength, arrayChildren); + arrayLength = bracket.value; + type += bracket.text; + baseType = "array"; + comps = null; + } + + let indexed = null; + const keywords = consumeKeywords(tokens, KwModifiers); + if (keywords.has("indexed")) { + if (!allowIndexed) { throw new Error(""); } + indexed = true; + } + + const name = (tokens.peekType("ID") ? tokens.pop().text: ""); + + if (tokens.length) { throw new Error("leftover tokens"); } + + return new ParamType(_guard, name, type, baseType, indexed, comps, arrayLength, arrayChildren); + } + + static isParamType(value: any): value is ParamType { + return (value && value[internal] === ParamTypeInternal); + } +} + +export enum FragmentType { + "constructor" = "constructor", + "error" = "error", + "event" = "event", + "function" = "function", + "struct" = "struct", +}; + +export abstract class Fragment { + readonly type!: FragmentType; + readonly inputs!: ReadonlyArray; + + constructor(guard: any, type: FragmentType, inputs: ReadonlyArray) { + logger.assertPrivate(guard, _guard, "Fragment"); + inputs = Object.freeze(inputs.slice()); + defineProperties(this, { type, inputs }); + } + + abstract format(format?: FormatType): string; + + static from(obj: any): Fragment { + if (typeof(obj) === "string") { return this.fromString(obj); } + if (obj instanceof TokenString) { return this.fromTokens(obj); } + if (typeof(obj) === "object") { return this.fromObject(obj); } + throw new Error(`unsupported type: ${ obj }`); + } + + static fromObject(obj: any): Fragment { + switch (obj.type) { + case "constructor": return ConstructorFragment.fromObject(obj); + case "error": return ErrorFragment.fromObject(obj); + case "event": return EventFragment.fromObject(obj); + case "function": return FunctionFragment.fromObject(obj); + case "struct": return StructFragment.fromObject(obj); + } + throw new Error("not implemented yet"); + } + + static fromString(text: string): Fragment { + try { + Fragment.from(JSON.parse(text)); + } catch (e) { } + + return Fragment.fromTokens(lex(text)); + } + + static fromTokens(tokens: TokenString): Fragment { + const type = tokens.popKeyword(KwTypes); + + switch (type) { + case "constructor": return ConstructorFragment.fromTokens(tokens); + case "error": return ErrorFragment.fromTokens(tokens); + case "event": return EventFragment.fromTokens(tokens); + case "function": return FunctionFragment.fromTokens(tokens); + case "struct": return StructFragment.fromTokens(tokens); + } + + throw new Error(`unsupported type: ${ type }`); + } + + /* + static fromTokens(tokens: TokenString): Fragment { + const assertDone = () => { + if (tokens.length) { throw new Error(`unexpected tokens: ${ tokens.toString() }`); } + }); + + const type = (tokens.length && tokens.peek().type === "KEYWORD") ? tokens.peek().text: "unknown"; + + const name = consumeName("error", tokens); + const inputs = consumeParams(tokens, type === "event"); + + switch (type) { + case "event": case "struct": + assertDone(); + } + + } + */ + + static isConstructor(value: any): value is ConstructorFragment { + return (value && value.type === "constructor"); + } + + static isError(value: any): value is ErrorFragment { + return (value && value.type === "error"); + } + + static isEvent(value: any): value is EventFragment { + return (value && value.type === "event"); + } + + static isFunction(value: any): value is FunctionFragment { + return (value && value.type === "function"); + } + + static isStruct(value: any): value is StructFragment { + return (value && value.type === "struct"); + } +} + +export abstract class NamedFragment extends Fragment { + readonly name!: string; + + constructor(guard: any, type: FragmentType, name: string, inputs: ReadonlyArray) { + super(guard, type, inputs); + inputs = Object.freeze(inputs.slice()); + defineProperties(this, { name }); + } +} + +function joinParams(format: FormatType, params: ReadonlyArray): string { + return "(" + params.map((p) => p.format(format)).join((format === FormatType.full) ? ", ": ",") + ")"; +} + +export class ErrorFragment extends NamedFragment { + constructor(guard: any, name: string, inputs: ReadonlyArray) { + super(guard, FragmentType.error, name, inputs); + } + + format(format: FormatType = FormatType.sighash): string { + if (!FormatType[format]) { + logger.throwArgumentError("invalid format type", "format", format); + } + + if (format === FormatType.json) { + return JSON.stringify({ + type: "error", + name: this.name, + inputs: this.inputs.map((input) => JSON.parse(input.format(format))), + }); + } + + const result = [ ]; + if (format !== FormatType.sighash) { result.push("error"); } + result.push(this.name + joinParams(format, this.inputs)); + return result.join(" "); + } + + static fromString(text: string): ErrorFragment { + return ErrorFragment.fromTokens(lex(text)); + } + + static fromTokens(tokens: TokenString): ErrorFragment { + const name = consumeName("error", tokens); + const inputs = consumeParams(tokens); + consumeEoi(tokens); + + return new ErrorFragment(_guard, name, inputs); + } +} + + +export class EventFragment extends NamedFragment { + readonly anonymous!: boolean; + + constructor(guard: any, name: string, inputs: ReadonlyArray, anonymous: boolean) { + super(guard, FragmentType.event, name, inputs); + defineProperties(this, { anonymous }); + } + + format(format: FormatType = FormatType.sighash): string { + if (!FormatType[format]) { + logger.throwArgumentError("invalid format type", "format", format); + } + + if (format === FormatType.json) { + return JSON.stringify({ + type: "event", + anonymous: this.anonymous, + name: this.name, + inputs: this.inputs.map((i) => JSON.parse(i.format(format))) + }); + } + + const result = [ ]; + if (format !== FormatType.sighash) { result.push("event"); } + result.push(this.name + joinParams(format, this.inputs)); + if (format !== FormatType.sighash && this.anonymous) { result.push("anonymous"); } + return result.join(" "); + } + + static fromString(text: string): EventFragment { + return EventFragment.fromTokens(lex(text)); + } + + static fromTokens(tokens: TokenString): EventFragment { + const name = consumeName("event", tokens); + const inputs = consumeParams(tokens, true); + const anonymous = !!consumeKeywords(tokens, setify([ "anonymous" ])).has("anonymous"); + consumeEoi(tokens); + + return new EventFragment(_guard, name, inputs, anonymous); + } +} + + +export class ConstructorFragment extends Fragment { + readonly payable!: boolean; + readonly gas!: null | bigint; + + constructor(guard: any, type: FragmentType, inputs: ReadonlyArray, payable: boolean, gas: null | bigint) { + super(guard, type, inputs); + defineProperties(this, { payable, gas }); + } + + format(format: FormatType = FormatType.sighash): string { + if (!FormatType[format]) { + logger.throwArgumentError("invalid format type", "format", format); + } + + if (format === FormatType.sighash) { + logger.throwError("cannot format a constructor for sighash", "UNSUPPORTED_OPERATION", { + operation: "format(sighash)" + }); + } + + if (format === FormatType.json) { + return JSON.stringify({ + type: "constructor", + stateMutability: (this.payable ? "payable": "undefined"), + payable: this.payable, + gas: ((this.gas != null) ? this.gas: undefined), + inputs: this.inputs.map((i) => JSON.parse(i.format(format))) + }); + } + + const result = [ `constructor${ joinParams(format, this.inputs) }` ]; + result.push((this.payable) ? "payable": "nonpayable"); + if (this.gas != null) { result.push(`@${ this.gas.toString() }`); } + return result.join(" "); + } + + static fromString(text: string): ConstructorFragment { + return ConstructorFragment.fromTokens(lex(text)); + } + + static fromObject(obj: any): ConstructorFragment { + throw new Error("TODO"); + } + + static fromTokens(tokens: TokenString): ConstructorFragment { + consumeKeywords(tokens, setify([ "constructor" ])); + const inputs = consumeParams(tokens); + const payable = !!consumeKeywords(tokens, setify([ "payable" ])).has("payable"); + const gas = consumeGas(tokens); + consumeEoi(tokens); + + return new ConstructorFragment(_guard, FragmentType.constructor, inputs, payable, gas); + } +} + +export class FunctionFragment extends NamedFragment { + readonly constant!: boolean; + readonly outputs!: ReadonlyArray; + readonly stateMutability!: string; + + readonly payable!: boolean; + readonly gas!: null | bigint; + + constructor(guard: any, name: string, stateMutability: string, inputs: ReadonlyArray, outputs: ReadonlyArray, gas: null | bigint) { + super(guard, FragmentType.function, name, inputs); + outputs = Object.freeze(outputs.slice()); + const constant = (stateMutability === "view" || stateMutability === "pure"); + const payable = (stateMutability === "payable"); + defineProperties(this, { constant, gas, outputs, payable, stateMutability }); + } + + format(format: FormatType = FormatType.sighash): string { + if (!FormatType[format]) { + logger.throwArgumentError("invalid format type", "format", format); + } + + if (format === FormatType.json) { + return JSON.stringify({ + type: "function", + name: this.name, + constant: this.constant, + stateMutability: ((this.stateMutability !== "nonpayable") ? this.stateMutability: undefined), + payable: this.payable, + gas: ((this.gas != null) ? this.gas: undefined), + inputs: this.inputs.map((i) => JSON.parse(i.format(format))), + outputs: this.outputs.map((o) => JSON.parse(o.format(format))), + }); + } + + const result = []; + + if (format !== FormatType.sighash) { result.push("function"); } + + result.push(this.name + joinParams(format, this.inputs)); + + if (format !== FormatType.sighash) { + if (this.stateMutability !== "nonpayable") { + result.push(this.stateMutability); + } + + if (this.outputs && this.outputs.length) { + result.push("returns"); + result.push(joinParams(format, this.outputs)); + } + + if (this.gas != null) { result.push(`@${ this.gas.toString() }`); } + } + return result.join(" "); + } + + static fromString(text: string): FunctionFragment { + return FunctionFragment.fromTokens(lex(text)); + } + + static fromTokens(tokens: TokenString): FunctionFragment { + const name = consumeName("function", tokens); + const inputs = consumeParams(tokens); + const mutability = consumeMutability(tokens); + + let outputs: Array = [ ]; + if (consumeKeywords(tokens, setify([ "returns" ])).has("returns")) { + outputs = consumeParams(tokens); + } + + const gas = consumeGas(tokens); + + consumeEoi(tokens); + + return new FunctionFragment(_guard, name, mutability, inputs, outputs, gas); + } +} + +export class StructFragment extends NamedFragment { + format(): string { + throw new Error("@TODO"); + } + + static fromString(text: string): StructFragment { + return StructFragment.fromTokens(lex(text)); + } + + static fromTokens(tokens: TokenString): StructFragment { + const name = consumeName("struct", tokens); + const inputs = consumeParams(tokens); + consumeEoi(tokens); + + return new StructFragment(_guard, FragmentType.struct, name, inputs); + } +} + diff --git a/src.ts/abi/index.ts b/src.ts/abi/index.ts new file mode 100644 index 000000000..333cd0c12 --- /dev/null +++ b/src.ts/abi/index.ts @@ -0,0 +1,38 @@ + +export { + AbiCoder, + defaultAbiCoder +} from "./abi-coder.js"; + +export { formatBytes32String, parseBytes32String } from "./bytes32.js"; + +export { + ConstructorFragment, + ErrorFragment, + EventFragment, + Fragment, + FunctionFragment, + ParamType +} from "./fragments.js"; + +export { + checkResultErrors, + Indexed, + Interface, + LogDescription, + Result, + TransactionDescription +} from "./interface.js"; + +export { Typed } from "./typed.js"; + +export type { + JsonFragment, + JsonFragmentType, +} from "./fragments.js"; + + +export type { + InterfaceAbi, +} from "./interface.js"; + diff --git a/src.ts/abi/interface.ts b/src.ts/abi/interface.ts new file mode 100644 index 000000000..3946f4819 --- /dev/null +++ b/src.ts/abi/interface.ts @@ -0,0 +1,905 @@ +import { concat, dataSlice, hexlify, zeroPadValue, isHexString } from "../utils/data.js"; +import { keccak256 } from "../crypto/index.js" +import { id } from "../hash/index.js" +import { logger } from "../utils/logger.js"; +import { defineProperties } from "../utils/properties.js"; +import { toHex } from "../utils/maths.js"; + +import { AbiCoder, defaultAbiCoder } from "./abi-coder.js"; +import { checkResultErrors, Result } from "./coders/abstract-coder.js"; +import { ConstructorFragment, ErrorFragment, EventFragment, FormatType, Fragment, FunctionFragment, ParamType } from "./fragments.js"; +import { Typed } from "./typed.js"; + +import type { BigNumberish, BytesLike } from "../utils/index.js"; + +import type { JsonFragment } from "./fragments.js"; + + +export { checkResultErrors, Result }; + +export class LogDescription { + readonly fragment!: EventFragment; + readonly name!: string; + readonly signature!: string; + readonly topic!: string; + readonly args!: Result + + constructor(fragment: EventFragment, topic: string, args: Result) { + const name = fragment.name, signature = fragment.format(); + defineProperties(this, { + fragment, name, signature, topic, args + }); + } +} + +export class TransactionDescription { + readonly fragment!: FunctionFragment; + readonly name!: string; + readonly args!: Result; + readonly signature!: string; + readonly selector!: string; + readonly value!: bigint; + + constructor(fragment: FunctionFragment, selector: string, args: Result, value: bigint) { + const name = fragment.name, signature = fragment.format(); + defineProperties(this, { + fragment, name, args, signature, selector, value + }); + } +} + +export class ErrorDescription { + readonly fragment!: ErrorFragment; + readonly name!: string; + readonly args!: Result; + readonly signature!: string; + readonly selector!: string; + + constructor(fragment: ErrorFragment, selector: string, args: Result) { + const name = fragment.name, signature = fragment.format(); + defineProperties(this, { + fragment, name, args, signature, selector + }); + } +} + +export class Indexed { + readonly hash!: null | string; + readonly _isIndexed!: boolean; + + static isIndexed(value: any): value is Indexed { + return !!(value && value._isIndexed); + } + + constructor(hash: null | string) { + defineProperties(this, { hash, _isIndexed: true }) + } +} + +type ErrorInfo = { + signature: string, + inputs: Array, + name: string, + reason: (...args: Array) => string; +}; + +// https://docs.soliditylang.org/en/v0.8.13/control-structures.html?highlight=panic#panic-via-assert-and-error-via-require +const PanicReasons: Record = { + "0": "generic panic", + "1": "assert(false)", + "17": "arithmetic overflow", + "18": "division or modulo by zero", + "33": "enum overflow", + "34": "invalid encoded storage byte array accessed", + "49": "out-of-bounds array access; popping on an empty array", + "50": "out-of-bounds access of an array or bytesN", + "65": "out of memory", + "81": "uninitialized function", +} + +const BuiltinErrors: Record = { + "0x08c379a0": { + signature: "Error(string)", + name: "Error", + inputs: [ "string" ], + reason: (message: string) => { + return `reverted with reason string ${ JSON.stringify(message) }`; + } + }, + "0x4e487b71": { + signature: "Panic(uint256)", + name: "Panic", + inputs: [ "uint256" ], + reason: (code: bigint) => { + let reason = "unknown panic code"; + if (code >= 0 && code <= 0xff && PanicReasons[code.toString()]) { + reason = PanicReasons[code.toString()]; + } + return `reverted with panic code 0x${ code.toString(16) } (${ reason })`; + } + } +} + +/* +function wrapAccessError(property: string, error: Error): Error { + const wrap = new Error(`deferred error during ABI decoding triggered accessing ${ property }`); + (wrap).error = error; + return wrap; +} +*/ +/* +function checkNames(fragment: Fragment, type: "input" | "output", params: Array): void { + params.reduce((accum, param) => { + if (param.name) { + if (accum[param.name]) { + logger.throwArgumentError(`duplicate ${ type } parameter ${ JSON.stringify(param.name) } in ${ fragment.format("full") }`, "fragment", fragment); + } + accum[param.name] = true; + } + return accum; + }, <{ [ name: string ]: boolean }>{ }); +} +*/ +//export type AbiCoder = any; +//const defaultAbiCoder: AbiCoder = { }; + +export type InterfaceAbi = string | ReadonlyArray; + +export class Interface { + readonly fragments!: ReadonlyArray; + + readonly deploy!: ConstructorFragment; + + #errors: Map; + #events: Map; + #functions: Map; +// #structs: Map; + + #abiCoder: AbiCoder; + + constructor(fragments: InterfaceAbi) { + let abi: ReadonlyArray = [ ]; + if (typeof(fragments) === "string") { + abi = JSON.parse(fragments); + } else { + abi = fragments; + } + + this.#functions = new Map(); + this.#errors = new Map(); + this.#events = new Map(); +// this.#structs = new Map(); + + defineProperties(this, { + fragments: Object.freeze(abi.map((f) => Fragment.from(f)).filter((f) => (f != null))), + }); + + this.#abiCoder = this.getAbiCoder(); + + // Add all fragments by their signature + this.fragments.forEach((fragment) => { + let bucket: Map; + switch (fragment.type) { + case "constructor": + if (this.deploy) { + logger.warn("duplicate definition - constructor"); + return; + } + //checkNames(fragment, "input", fragment.inputs); + defineProperties(this, { deploy: fragment }); + return; + + case "function": + //checkNames(fragment, "input", fragment.inputs); + //checkNames(fragment, "output", (fragment).outputs); + bucket = this.#functions; + break; + + case "event": + //checkNames(fragment, "input", fragment.inputs); + bucket = this.#events; + break; + + case "error": + bucket = this.#errors; + break; + + default: + return; + } + + const signature = fragment.format(); + if (bucket.has(signature)) { + logger.warn("duplicate definition - " + signature); + return; + } + + bucket.set(signature, fragment); + }); + + // If we do not have a constructor add a default + if (!this.deploy) { + defineProperties(this, { + deploy: ConstructorFragment.fromString("constructor()") + }); + } + } +// @TODO: multi sig? + format(format?: FormatType): string | Array { + if (!format) { format = FormatType.full; } + if (format === FormatType.sighash) { + logger.throwArgumentError("interface does not support formatting sighash", "format", format); + } + + const abi = this.fragments.map((f) => f.format(format)); + + // We need to re-bundle the JSON fragments a bit + if (format === FormatType.json) { + return JSON.stringify(abi.map((j) => JSON.parse(j))); + } + + return abi; + } + + getAbiCoder(): AbiCoder { + return defaultAbiCoder; + } + + //static getAddress(address: string): string { + // return getAddress(address); + //} + + //static getSelector(fragment: ErrorFragment | FunctionFragment): string { + // return dataSlice(id(fragment.format()), 0, 4); + //} + + //static getEventTopic(eventFragment: EventFragment): string { + // return id(eventFragment.format()); + //} + + // Find a function definition by any means necessary (unless it is ambiguous) + #getFunction(key: string, values: null | Array, forceUnique: boolean): FunctionFragment { + + // Selector + if (isHexString(key)) { + const selector = key.toLowerCase(); + for (const fragment of this.#functions.values()) { + if (selector === this.getSelector(fragment)) { return fragment; } + } + logger.throwArgumentError("no matching function", "selector", key); + } + + // It is a bare name, look up the function (will return null if ambiguous) + if (key.indexOf("(") === -1) { + const matching: Array = [ ]; + for (const [ name, fragment ] of this.#functions) { + if (name.split("("/* fix:) */)[0] === key) { matching.push(fragment); } + } + + if (values) { + const lastValue = (values.length > 0) ? values[values.length - 1]: null; + + let valueLength = values.length; + let allowOptions = true; + if (Typed.isTyped(lastValue) && lastValue.type === "overrides") { + allowOptions = false; + valueLength--; + } + + // Remove all matches that don't have a compatible length. The args + // may contain an overrides, so the match may have n or n - 1 parameters + for (let i = matching.length - 1; i >= 0; i--) { + const inputs = matching[i].inputs.length; + if (inputs !== valueLength && (!allowOptions || inputs !== valueLength - 1)) { + matching.splice(i, 1); + } + } + + // Remove all matches that don't match the Typed signature + for (let i = matching.length - 1; i >= 0; i--) { + const inputs = matching[i].inputs; + for (let j = 0; j < values.length; j++) { + // Not a typed value + if (!Typed.isTyped(values[j])) { continue; } + + // We are past the inputs + if (j >= inputs.length) { + if (values[j].type === "overrides") { continue; } + matching.splice(i, 1); + break; + } + + // Make sure the value type matches the input type + if (values[j].type !== inputs[j].baseType) { + matching.splice(i, 1); + break; + } + } + } + } + + // We found a single matching signature with an overrides, but the + // last value is something that cannot possibly be an options + if (matching.length === 1 && values && values.length !== matching[0].inputs.length) { + const lastArg = values[values.length - 1]; + if (lastArg == null || Array.isArray(lastArg) || typeof(lastArg) !== "object") { + matching.splice(0, 1); + } + } + + if (matching.length === 0) { + logger.throwArgumentError("no matching function", "name", key); + + } else if (matching.length > 1 && forceUnique) { + const matchStr = matching.map((m) => JSON.stringify(m.format())).join(", "); + logger.throwArgumentError(`multiple matching functions (i.e. ${ matchStr })`, "name", key); + } + + return matching[0]; + } + + // Normalize the signature and lookup the function + const result = this.#functions.get(FunctionFragment.fromString(key).format()); + if (result) { return result; } + + return logger.throwArgumentError("no matching function", "signature", key); + } + getFunctionName(key: string): string { + return (this.#getFunction(key, null, false)).name; + } + getFunction(key: string, values?: Array): FunctionFragment { + return this.#getFunction(key, values || null, true) + } + + + // Find an event definition by any means necessary (unless it is ambiguous) + #getEvent(key: string, values: null | Array, forceUnique: boolean): EventFragment { + + // EventTopic + if (isHexString(key)) { + const eventTopic = key.toLowerCase(); + for (const fragment of this.#events.values()) { + if (eventTopic === this.getEventTopic(fragment)) { return fragment; } + } + logger.throwArgumentError("no matching event", "eventTopic", key); + } + + // It is a bare name, look up the function (will return null if ambiguous) + if (key.indexOf("(") === -1) { + const matching = [ ]; + for (const [ name, fragment ] of this.#events) { + if (name.split("("/* fix:) */)[0] === key) { matching.push(fragment); } + } + + if (values) { + // Remove all matches that don't have a compatible length. + for (let i = matching.length - 1; i >= 0; i--) { + if (matching[i].inputs.length < values.length) { + matching.splice(i, 1); + } + } + + // Remove all matches that don't match the Typed signature + for (let i = matching.length - 1; i >= 0; i--) { + const inputs = matching[i].inputs; + for (let j = 0; j < values.length; j++) { + // Not a typed value + if (!Typed.isTyped(values[j])) { continue; } + + // Make sure the value type matches the input type + if (values[j].type !== inputs[j].baseType) { + matching.splice(i, 1); + break; + } + } + } + } + + if (matching.length === 0) { + logger.throwArgumentError("no matching event", "name", key); + } else if (matching.length > 1 && forceUnique) { + // @TODO: refine by Typed + logger.throwArgumentError("multiple matching events", "name", key); + } + + return matching[0]; + } + + // Normalize the signature and lookup the function + const result = this.#events.get(EventFragment.fromString(key).format()); + if (result) { return result; } + + return logger.throwArgumentError("no matching event", "signature", key); + } + getEventName(key: string): string { + return (this.#getEvent(key, null, false)).name; + } + getEvent(key: string, values?: Array): EventFragment { + return this.#getEvent(key, values || null, true) + } + + // Find a function definition by any means necessary (unless it is ambiguous) + getError(key: string, values?: Array): ErrorFragment { + if (isHexString(key)) { + const selector = key.toLowerCase(); + + if (BuiltinErrors[selector]) { + return ErrorFragment.fromString(BuiltinErrors[selector].signature); + } + + for (const fragment of this.#errors.values()) { + if (selector === this.getSelector(fragment)) { return fragment; } + } + logger.throwArgumentError("no matching error", "selector", key); + } + + // It is a bare name, look up the function (will return null if ambiguous) + if (key.indexOf("(") === -1) { + const matching = [ ]; + for (const [ name, fragment ] of this.#errors) { + if (name.split("("/* fix:) */)[0] === key) { matching.push(fragment); } + } + + if (matching.length === 0) { + if (key === "Error") { return ErrorFragment.fromString("error Error(string)"); } + if (key === "Panic") { return ErrorFragment.fromString("error Panic(uint256)"); } + logger.throwArgumentError("no matching error", "name", key); + } else if (matching.length > 1) { + // @TODO: refine by Typed + logger.throwArgumentError("multiple matching errors", "name", key); + } + + return matching[0]; + } + + // Normalize the signature and lookup the function + key = ErrorFragment.fromString(key).format() + if (key === "Error(string)") { return ErrorFragment.fromString("error Error(string)"); } + if (key === "Panic(uint256)") { return ErrorFragment.fromString("error Panic(uint256)"); } + + const result = this.#errors.get(key); + if (result) { return result; } + + return logger.throwArgumentError("no matching error", "signature", key); + } + + // Get the 4-byte selector used by Solidity to identify a function + getSelector(fragment: ErrorFragment | FunctionFragment): string { + /* + if (typeof(fragment) === "string") { + const matches: Array = [ ]; + + try { matches.push(this.getFunction(fragment)); } catch (error) { } + try { matches.push(this.getError(fragment)); } catch (_) { } + + if (matches.length === 0) { + logger.throwArgumentError("unknown fragment", "key", fragment); + } else if (matches.length > 1) { + logger.throwArgumentError("ambiguous fragment matches function and error", "key", fragment); + } + + fragment = matches[0]; + } + */ + + return dataSlice(id(fragment.format()), 0, 4); + } + + // Get the 32-byte topic hash used by Solidity to identify an event + getEventTopic(fragment: EventFragment): string { + //if (typeof(fragment) === "string") { fragment = this.getEvent(eventFragment); } + return id(fragment.format()); + } + + + _decodeParams(params: ReadonlyArray, data: BytesLike): Result { + return this.#abiCoder.decode(params, data) + } + + _encodeParams(params: ReadonlyArray, values: ReadonlyArray): string { + return this.#abiCoder.encode(params, values) + } + + encodeDeploy(values?: ReadonlyArray): string { + return this._encodeParams(this.deploy.inputs, values || [ ]); + } + + decodeErrorResult(fragment: ErrorFragment | string, data: BytesLike): Result { + if (typeof(fragment) === "string") { fragment = this.getError(fragment); } + + if (dataSlice(data, 0, 4) !== this.getSelector(fragment)) { + logger.throwArgumentError(`data signature does not match error ${ fragment.name }.`, "data", data); + } + + return this._decodeParams(fragment.inputs, dataSlice(data, 4)); + } + + encodeErrorResult(fragment: ErrorFragment | string, values?: ReadonlyArray): string { + if (typeof(fragment) === "string") { fragment = this.getError(fragment); } + + return concat([ + this.getSelector(fragment), + this._encodeParams(fragment.inputs, values || [ ]) + ]); + } + + // Decode the data for a function call (e.g. tx.data) + decodeFunctionData(fragment: FunctionFragment | string, data: BytesLike): Result { + if (typeof(fragment) === "string") { fragment = this.getFunction(fragment); } + + if (dataSlice(data, 0, 4) !== this.getSelector(fragment)) { + logger.throwArgumentError(`data signature does not match function ${ fragment.name }.`, "data", data); + } + + return this._decodeParams(fragment.inputs, dataSlice(data, 4)); + } + + // Encode the data for a function call (e.g. tx.data) + encodeFunctionData(fragment: FunctionFragment | string, values?: ReadonlyArray): string { + if (typeof(fragment) === "string") { fragment = this.getFunction(fragment); } + + return concat([ + this.getSelector(fragment), + this._encodeParams(fragment.inputs, values || [ ]) + ]); + } + + // Decode the result from a function call (e.g. from eth_call) + decodeFunctionResult(fragment: FunctionFragment | string, data: BytesLike): Result { + if (typeof(fragment) === "string") { fragment = this.getFunction(fragment); } + + let message = "invalid length for result data"; + + const bytes = logger.getBytesCopy(data); + if ((bytes.length % 32) === 0) { + try { + return this.#abiCoder.decode(fragment.outputs, bytes); + } catch (error) { + message = "could not decode result data"; + } + } + + // Call returned data with no error, but the data is junk + return logger.throwError(message, "BAD_DATA", { + value: hexlify(bytes), + info: { method: fragment.name, signature: fragment.format() } + }); + } + + makeError(fragment: FunctionFragment | string, _data: BytesLike, tx?: { data: string }): Error { + if (typeof(fragment) === "string") { fragment = this.getFunction(fragment); } + + const data = logger.getBytes(_data); + + let args: undefined | Result = undefined; + if (tx) { + try { + args = this.#abiCoder.decode(fragment.inputs, tx.data || "0x"); + } catch (error) { console.log(error); } + } + + let errorArgs: undefined | Result = undefined; + let errorName: undefined | string = undefined; + let errorSignature: undefined | string = undefined; + let reason: string = "unknown reason"; + + if (data.length === 0) { + reason = "missing error reason"; + + } else if ((data.length % 32) === 4) { + const selector = hexlify(data.slice(0, 4)); + const builtin = BuiltinErrors[selector]; + if (builtin) { + try { + errorName = builtin.name; + errorSignature = builtin.signature; + errorArgs = this.#abiCoder.decode(builtin.inputs, data.slice(4)); + reason = builtin.reason(...errorArgs); + } catch (error) { + console.log(error); // @TODO: remove + } + } else { + reason = "unknown custom error"; + try { + const error = this.getError(selector); + errorName = error.name; + errorSignature = error.format(); + reason = `custom error: ${ errorSignature }`; + try { + errorArgs = this.#abiCoder.decode(error.inputs, data.slice(4)); + } catch (error) { + reason = `custom error: ${ errorSignature } (coult not decode error data)` + } + } catch (error) { + console.log(error); // @TODO: remove + } + } + } + + return logger.makeError("call revert exception", "CALL_EXCEPTION", { + data: hexlify(data), transaction: null, + method: fragment.name, signature: fragment.format(), args, + errorArgs, errorName, errorSignature, reason + }); + } + + // Encode the result for a function call (e.g. for eth_call) + encodeFunctionResult(functionFragment: FunctionFragment | string, values?: ReadonlyArray): string { + if (typeof(functionFragment) === "string") { + functionFragment = this.getFunction(functionFragment); + } + + return hexlify(this.#abiCoder.encode(functionFragment.outputs, values || [ ])); + } +/* + spelunk(inputs: Array, values: ReadonlyArray, processfunc: (type: string, value: any) => Promise): Promise> { + const promises: Array> = [ ]; + const process = function(type: ParamType, value: any): any { + if (type.baseType === "array") { + return descend(type.child + } + if (type. === "address") { + } + }; + + const descend = function (inputs: Array, values: ReadonlyArray) { + if (inputs.length !== values.length) { throw new Error("length mismatch"); } + + }; + + const result: Array = [ ]; + values.forEach((value, index) => { + if (value == null) { + topics.push(null); + } else if (param.baseType === "array" || param.baseType === "tuple") { + logger.throwArgumentError("filtering with tuples or arrays not supported", ("contract." + param.name), value); + } else if (Array.isArray(value)) { + topics.push(value.map((value) => encodeTopic(param, value))); + } else { + topics.push(encodeTopic(param, value)); + } + }); + } +*/ + // Create the filter for the event with search criteria (e.g. for eth_filterLog) + encodeFilterTopics(eventFragment: EventFragment, values: ReadonlyArray): Array> { + if (typeof(eventFragment) === "string") { + eventFragment = this.getEvent(eventFragment); + } + + if (values.length > eventFragment.inputs.length) { + logger.throwError("too many arguments for " + eventFragment.format(), "UNEXPECTED_ARGUMENT", { + count: values.length, + expectedCount: eventFragment.inputs.length + }) + } + + const topics: Array> = []; + if (!eventFragment.anonymous) { topics.push(this.getEventTopic(eventFragment)); } + + // @TODO: Use the coders for this; to properly support tuples, etc. + const encodeTopic = (param: ParamType, value: any): string => { + if (param.type === "string") { + return id(value); + } else if (param.type === "bytes") { + return keccak256(hexlify(value)); + } + + if (param.type === "bool" && typeof(value) === "boolean") { + value = (value ? "0x01": "0x00"); + } + + if (param.type.match(/^u?int/)) { + value = toHex(value); + } + + // Check addresses are valid + if (param.type === "address") { this.#abiCoder.encode( [ "address" ], [ value ]); } + return zeroPadValue(hexlify(value), 32); + //@TOOD should probably be return toHex(value, 32) + }; + + values.forEach((value, index) => { + + const param = eventFragment.inputs[index]; + + if (!param.indexed) { + if (value != null) { + logger.throwArgumentError("cannot filter non-indexed parameters; must be null", ("contract." + param.name), value); + } + return; + } + + if (value == null) { + topics.push(null); + } else if (param.baseType === "array" || param.baseType === "tuple") { + logger.throwArgumentError("filtering with tuples or arrays not supported", ("contract." + param.name), value); + } else if (Array.isArray(value)) { + topics.push(value.map((value) => encodeTopic(param, value))); + } else { + topics.push(encodeTopic(param, value)); + } + }); + + // Trim off trailing nulls + while (topics.length && topics[topics.length - 1] === null) { + topics.pop(); + } + + return topics; + } + + encodeEventLog(eventFragment: EventFragment, values: ReadonlyArray): { data: string, topics: Array } { + if (typeof(eventFragment) === "string") { + eventFragment = this.getEvent(eventFragment); + } + + const topics: Array = [ ]; + + const dataTypes: Array = [ ]; + const dataValues: Array = [ ]; + + if (!eventFragment.anonymous) { + topics.push(this.getEventTopic(eventFragment)); + } + + if (values.length !== eventFragment.inputs.length) { + logger.throwArgumentError("event arguments/values mismatch", "values", values); + } + + eventFragment.inputs.forEach((param, index) => { + const value = values[index]; + if (param.indexed) { + if (param.type === "string") { + topics.push(id(value)) + } else if (param.type === "bytes") { + topics.push(keccak256(value)) + } else if (param.baseType === "tuple" || param.baseType === "array") { + // @TODO + throw new Error("not implemented"); + } else { + topics.push(this.#abiCoder.encode([ param.type] , [ value ])); + } + } else { + dataTypes.push(param); + dataValues.push(value); + } + }); + + return { + data: this.#abiCoder.encode(dataTypes , dataValues), + topics: topics + }; + } + + // Decode a filter for the event and the search criteria + decodeEventLog(eventFragment: EventFragment | string, data: BytesLike, topics?: ReadonlyArray): Result { + if (typeof(eventFragment) === "string") { + eventFragment = this.getEvent(eventFragment); + } + + if (topics != null && !eventFragment.anonymous) { + const eventTopic = this.getEventTopic(eventFragment); + if (!isHexString(topics[0], 32) || topics[0].toLowerCase() !== eventTopic) { + logger.throwArgumentError("fragment/topic mismatch", "topics[0]", topics[0]); + } + topics = topics.slice(1); + } + + const indexed: Array = []; + const nonIndexed: Array = []; + const dynamic: Array = []; + + eventFragment.inputs.forEach((param, index) => { + if (param.indexed) { + if (param.type === "string" || param.type === "bytes" || param.baseType === "tuple" || param.baseType === "array") { + indexed.push(ParamType.fromObject({ type: "bytes32", name: param.name })); + dynamic.push(true); + } else { + indexed.push(param); + dynamic.push(false); + } + } else { + nonIndexed.push(param); + dynamic.push(false); + } + }); + + const resultIndexed = (topics != null) ? this.#abiCoder.decode(indexed, concat(topics)): null; + const resultNonIndexed = this.#abiCoder.decode(nonIndexed, data, true); + + //const result: (Array & { [ key: string ]: any }) = [ ]; + const values: Array = [ ]; + const keys: Array = [ ]; + let nonIndexedIndex = 0, indexedIndex = 0; + eventFragment.inputs.forEach((param, index) => { + let value = null; + if (param.indexed) { + if (resultIndexed == null) { + value = new Indexed(null); + + } else if (dynamic[index]) { + value = new Indexed(resultIndexed[indexedIndex++]); + + } else { + try { + value = resultIndexed[indexedIndex++]; + } catch (error) { + value = error; + } + } + } else { + try { + value = resultNonIndexed[nonIndexedIndex++]; + } catch (error) { + value = error; + } + } + + values.push(value); + keys.push(param.name || null); + }); + + return Result.fromItems(values, keys); + } + + // Given a transaction, find the matching function fragment (if any) and + // determine all its properties and call parameters + parseTransaction(tx: { data: string, value?: BigNumberish }): null | TransactionDescription { + const data = logger.getBytes(tx.data, "tx.data"); + const value = logger.getBigInt((tx.value != null) ? tx.value: 0, "tx.value"); + + const fragment = this.getFunction(hexlify(data.slice(0, 4))); + + if (!fragment) { return null; } + + const args = this.#abiCoder.decode(fragment.inputs, data.slice(4)); + return new TransactionDescription(fragment, this.getSelector(fragment), args, value); + } + + // @TODO + //parseCallResult(data: BytesLike): ?? + + // Given an event log, find the matching event fragment (if any) and + // determine all its properties and values + parseLog(log: { topics: Array, data: string}): null | LogDescription { + const fragment = this.getEvent(log.topics[0]); + + if (!fragment || fragment.anonymous) { return null; } + + // @TODO: If anonymous, and the only method, and the input count matches, should we parse? + // Probably not, because just because it is the only event in the ABI does + // not mean we have the full ABI; maybe just a fragment? + + + return new LogDescription(fragment, this.getEventTopic(fragment), this.decodeEventLog(fragment, log.data, log.topics)); + } + + parseError(data: BytesLike): null | ErrorDescription { + const hexData = hexlify(data); + + const fragment = this.getError(dataSlice(hexData, 0, 4)); + + if (!fragment) { return null; } + + const args = this.#abiCoder.decode(fragment.inputs, dataSlice(hexData, 4)); + return new ErrorDescription(fragment, this.getSelector(fragment), args); + } + + + static from(value: ReadonlyArray | string | Interface) { + // Already an Interface, which is immutable + if (value instanceof Interface) { return value; } + + // JSON + if (typeof(value) === "string") { return new Interface(JSON.parse(value)); } + + // Maybe an interface from an older version, or from a symlinked copy + if (typeof((value).format) === "function") { + return new Interface((value).format(FormatType.json)); + } + + // Array of fragments + return new Interface(value); + } +} diff --git a/src.ts/abi/typed.ts b/src.ts/abi/typed.ts new file mode 100644 index 000000000..a41dd7f5a --- /dev/null +++ b/src.ts/abi/typed.ts @@ -0,0 +1,254 @@ +import { defineProperties } from "../utils/properties.js"; + +import type { Addressable } from "../address/index.js"; +import type { BigNumberish, BytesLike } from "../utils/index.js"; + +import type { Result } from "./coders/abstract-coder.js"; + +const _gaurd = { }; + +function n(value: BigNumberish, width: number): Typed { + let signed = false; + if (width < 0) { + signed = true; + width *= -1; + } + + // @TODO: Check range is valid for value + return new Typed(_gaurd, `${ signed ? "": "u" }int${ width }`, value, { signed, width }); +} + +function b(value: BytesLike, size?: number): Typed { + // @TODO: Check range is valid for value + return new Typed(_gaurd, `bytes${ (size) ? size: "" }`, value, { size }); +} + +export interface TypedNumber extends Typed { + defaultValue(): number; + minValue(): number; + maxValue(): number; +} + +export interface TypedBigInt extends Typed { + defaultValue(): bigint; + minValue(): bigint; + maxValue(): bigint; +} + +export interface TypedData extends Typed { + defaultValue(): string; +} + +export interface TypedString extends Typed { + defaultValue(): string; +} + +const _typedSymbol = Symbol.for("_ethers_typed"); + +export class Typed { + readonly type!: string; + readonly value!: any; + + readonly #options: any; + + readonly _typedSymbol!: Symbol; + + constructor(gaurd: any, type: string, value: any, options: any = null) { + if (gaurd !== _gaurd) { throw new Error("private constructor"); } + defineProperties(this, { _typedSymbol, type, value }); + this.#options = options; + + // Check the value is valid + this.format(); + } + + format(): string { + if (this.type === "array") { + throw new Error(""); + } else if (this.type === "dynamicArray") { + throw new Error(""); + } else if (this.type === "tuple") { + return `tuple(${ this.value.map((v: Typed) => v.format()).join(",") })` + } + + return this.type; + } + + defaultValue(): string | number | bigint | Result { + return 0; + } + + minValue(): string | number | bigint { + return 0; + } + + maxValue(): string | number | bigint { + return 0; + } + + isBigInt(): this is TypedBigInt { + return !!(this.type.match(/^u?int[0-9]+$/)); + } + + isData(): this is TypedData { + return (this.type.substring(0, 5) === "bytes"); + } + + isString(): this is TypedString { + return (this.type === "string"); + } + + get tupleName(): null | string { + if (this.type !== "tuple") { throw TypeError("not a tuple"); } + return this.#options; + } + + // Returns the length of this type as an array + // - `null` indicates the length is unforced, it could be dynamic + // - `-1` indicates the length is dynamic + // - any other value indicates it is a static array and is its length + get arrayLength(): null | number { + if (this.type !== "array") { throw TypeError("not an array"); } + if (this.#options === true) { return -1; } + if (this.#options === false) { return (>(this.value)).length; } + return null; + } + + static from(type: string, value: any): Typed { + return new Typed(_gaurd, type, value); + } + + static uint8(v: BigNumberish): Typed { return n(v, 8); } + static uint16(v: BigNumberish): Typed { return n(v, 16); } + static uint24(v: BigNumberish): Typed { return n(v, 24); } + static uint32(v: BigNumberish): Typed { return n(v, 32); } + static uint40(v: BigNumberish): Typed { return n(v, 40); } + static uint48(v: BigNumberish): Typed { return n(v, 46); } + static uint56(v: BigNumberish): Typed { return n(v, 56); } + static uint64(v: BigNumberish): Typed { return n(v, 64); } + static uint72(v: BigNumberish): Typed { return n(v, 72); } + static uint80(v: BigNumberish): Typed { return n(v, 80); } + static uint88(v: BigNumberish): Typed { return n(v, 88); } + static uint96(v: BigNumberish): Typed { return n(v, 96); } + static uint104(v: BigNumberish): Typed { return n(v, 104); } + static uint112(v: BigNumberish): Typed { return n(v, 112); } + static uint120(v: BigNumberish): Typed { return n(v, 120); } + static uint128(v: BigNumberish): Typed { return n(v, 128); } + static uint136(v: BigNumberish): Typed { return n(v, 136); } + static uint144(v: BigNumberish): Typed { return n(v, 144); } + static uint152(v: BigNumberish): Typed { return n(v, 152); } + static uint160(v: BigNumberish): Typed { return n(v, 160); } + static uint168(v: BigNumberish): Typed { return n(v, 168); } + static uint176(v: BigNumberish): Typed { return n(v, 176); } + static uint184(v: BigNumberish): Typed { return n(v, 184); } + static uint192(v: BigNumberish): Typed { return n(v, 192); } + static uint200(v: BigNumberish): Typed { return n(v, 200); } + static uint208(v: BigNumberish): Typed { return n(v, 208); } + static uint216(v: BigNumberish): Typed { return n(v, 216); } + static uint224(v: BigNumberish): Typed { return n(v, 224); } + static uint232(v: BigNumberish): Typed { return n(v, 232); } + static uint240(v: BigNumberish): Typed { return n(v, 240); } + static uint248(v: BigNumberish): Typed { return n(v, 248); } + static uint256(v: BigNumberish): Typed { return n(v, 256); } + static uint(v: BigNumberish): Typed { return n(v, 256); } + + static int8(v: BigNumberish): Typed { return n(v, -8); } + static int16(v: BigNumberish): Typed { return n(v, -16); } + static int24(v: BigNumberish): Typed { return n(v, -24); } + static int32(v: BigNumberish): Typed { return n(v, -32); } + static int40(v: BigNumberish): Typed { return n(v, -40); } + static int48(v: BigNumberish): Typed { return n(v, -46); } + static int56(v: BigNumberish): Typed { return n(v, -56); } + static int64(v: BigNumberish): Typed { return n(v, -64); } + static int72(v: BigNumberish): Typed { return n(v, -72); } + static int80(v: BigNumberish): Typed { return n(v, -80); } + static int88(v: BigNumberish): Typed { return n(v, -88); } + static int96(v: BigNumberish): Typed { return n(v, -96); } + static int104(v: BigNumberish): Typed { return n(v, -104); } + static int112(v: BigNumberish): Typed { return n(v, -112); } + static int120(v: BigNumberish): Typed { return n(v, -120); } + static int128(v: BigNumberish): Typed { return n(v, -128); } + static int136(v: BigNumberish): Typed { return n(v, -136); } + static int144(v: BigNumberish): Typed { return n(v, -144); } + static int152(v: BigNumberish): Typed { return n(v, -152); } + static int160(v: BigNumberish): Typed { return n(v, -160); } + static int168(v: BigNumberish): Typed { return n(v, -168); } + static int176(v: BigNumberish): Typed { return n(v, -176); } + static int184(v: BigNumberish): Typed { return n(v, -184); } + static int192(v: BigNumberish): Typed { return n(v, -192); } + static int200(v: BigNumberish): Typed { return n(v, -200); } + static int208(v: BigNumberish): Typed { return n(v, -208); } + static int216(v: BigNumberish): Typed { return n(v, -216); } + static int224(v: BigNumberish): Typed { return n(v, -224); } + static int232(v: BigNumberish): Typed { return n(v, -232); } + static int240(v: BigNumberish): Typed { return n(v, -240); } + static int248(v: BigNumberish): Typed { return n(v, -248); } + static int256(v: BigNumberish): Typed { return n(v, -256); } + static int(v: BigNumberish): Typed { return n(v, -256); } + + static bytes(v: BytesLike): Typed { return b(v); } + static bytes1(v: BytesLike): Typed { return b(v, 1); } + static bytes2(v: BytesLike): Typed { return b(v, 2); } + static bytes3(v: BytesLike): Typed { return b(v, 3); } + static bytes4(v: BytesLike): Typed { return b(v, 4); } + static bytes5(v: BytesLike): Typed { return b(v, 5); } + static bytes6(v: BytesLike): Typed { return b(v, 6); } + static bytes7(v: BytesLike): Typed { return b(v, 7); } + static bytes8(v: BytesLike): Typed { return b(v, 8); } + static bytes9(v: BytesLike): Typed { return b(v, 9); } + static bytes10(v: BytesLike): Typed { return b(v, 10); } + static bytes11(v: BytesLike): Typed { return b(v, 11); } + static bytes12(v: BytesLike): Typed { return b(v, 12); } + static bytes13(v: BytesLike): Typed { return b(v, 13); } + static bytes14(v: BytesLike): Typed { return b(v, 14); } + static bytes15(v: BytesLike): Typed { return b(v, 15); } + static bytes16(v: BytesLike): Typed { return b(v, 16); } + static bytes17(v: BytesLike): Typed { return b(v, 17); } + static bytes18(v: BytesLike): Typed { return b(v, 18); } + static bytes19(v: BytesLike): Typed { return b(v, 19); } + static bytes20(v: BytesLike): Typed { return b(v, 20); } + static bytes21(v: BytesLike): Typed { return b(v, 21); } + static bytes22(v: BytesLike): Typed { return b(v, 22); } + static bytes23(v: BytesLike): Typed { return b(v, 23); } + static bytes24(v: BytesLike): Typed { return b(v, 24); } + static bytes25(v: BytesLike): Typed { return b(v, 25); } + static bytes26(v: BytesLike): Typed { return b(v, 26); } + static bytes27(v: BytesLike): Typed { return b(v, 27); } + static bytes28(v: BytesLike): Typed { return b(v, 28); } + static bytes29(v: BytesLike): Typed { return b(v, 29); } + static bytes30(v: BytesLike): Typed { return b(v, 30); } + static bytes31(v: BytesLike): Typed { return b(v, 31); } + static bytes32(v: BytesLike): Typed { return b(v, 32); } + + static address(v: string | Addressable): Typed { return new Typed(_gaurd, "address", v); } + static bool(v: any): Typed { return new Typed(_gaurd, "bool", !!v); } + static string(v: string): Typed { return new Typed(_gaurd, "string", v); } + + static array(v: Array, dynamic?: null | boolean): Typed { + throw new Error("not implemented yet"); + return new Typed(_gaurd, "array", v, dynamic); + } + + static tuple(v: Array | Record, name?: string): Typed { + throw new Error("not implemented yet"); + return new Typed(_gaurd, "tuple", v, name); + } + + static overrides(v: Record): Typed { + return new Typed(_gaurd, "overrides", Object.assign({ }, v)); + } + + static isTyped(value: any): value is Typed { + return (value && value._typedSymbol === _typedSymbol); + } + + static dereference(value: Typed | T, type: string): T { + if (Typed.isTyped(value)) { + if (value.type !== type) { + throw new Error(`invalid type: expecetd ${ type }, got ${ value.type }`); + } + return value.value; + } + return value; + } +} diff --git a/src.ts/address/address.ts b/src.ts/address/address.ts new file mode 100644 index 000000000..e6fc16be0 --- /dev/null +++ b/src.ts/address/address.ts @@ -0,0 +1,125 @@ +import { keccak256 } from "../crypto/keccak.js"; +import { logger } from "../utils/logger.js"; + + +const BN_0 = BigInt(0); +const BN_36 = BigInt(36); + +function getChecksumAddress(address: string): string { +// if (!isHexString(address, 20)) { +// logger.throwArgumentError("invalid address", "address", address); +// } + + address = address.toLowerCase(); + + const chars = address.substring(2).split(""); + + const expanded = new Uint8Array(40); + for (let i = 0; i < 40; i++) { + expanded[i] = chars[i].charCodeAt(0); + } + + const hashed = logger.getBytes(keccak256(expanded)); + + for (let i = 0; i < 40; i += 2) { + if ((hashed[i >> 1] >> 4) >= 8) { + chars[i] = chars[i].toUpperCase(); + } + if ((hashed[i >> 1] & 0x0f) >= 8) { + chars[i + 1] = chars[i + 1].toUpperCase(); + } + } + + return "0x" + chars.join(""); +} + +// See: https://en.wikipedia.org/wiki/International_Bank_Account_Number + +// Create lookup table +const ibanLookup: { [character: string]: string } = { }; +for (let i = 0; i < 10; i++) { ibanLookup[String(i)] = String(i); } +for (let i = 0; i < 26; i++) { ibanLookup[String.fromCharCode(65 + i)] = String(10 + i); } + +// How many decimal digits can we process? (for 64-bit float, this is 15) +// i.e. Math.floor(Math.log10(Number.MAX_SAFE_INTEGER)); +const safeDigits = 15; + +function ibanChecksum(address: string): string { + address = address.toUpperCase(); + address = address.substring(4) + address.substring(0, 2) + "00"; + + let expanded = address.split("").map((c) => { return ibanLookup[c]; }).join(""); + + // Javascript can handle integers safely up to 15 (decimal) digits + while (expanded.length >= safeDigits){ + let block = expanded.substring(0, safeDigits); + expanded = parseInt(block, 10) % 97 + expanded.substring(block.length); + } + + let checksum = String(98 - (parseInt(expanded, 10) % 97)); + while (checksum.length < 2) { checksum = "0" + checksum; } + + return checksum; +}; + +const Base36 = (function() {; + const result: Record = { }; + for (let i = 0; i < 36; i++) { + const key = "0123456789abcdefghijklmnopqrstuvwxyz"[i]; + result[key] = BigInt(i); + } + return result; +})(); + +function fromBase36(value: string): bigint { + value = value.toLowerCase(); + + let result = BN_0; + for (let i = 0; i < value.length; i++) { + result = result * BN_36 + Base36[value[i]]; + } + return result; +} + +export function getAddress(address: string): string { + + if (typeof(address) !== "string") { + logger.throwArgumentError("invalid address", "address", address); + } + + if (address.match(/^(0x)?[0-9a-fA-F]{40}$/)) { + + // Missing the 0x prefix + if (address.substring(0, 2) !== "0x") { address = "0x" + address; } + + const result = getChecksumAddress(address); + + // It is a checksummed address with a bad checksum + if (address.match(/([A-F].*[a-f])|([a-f].*[A-F])/) && result !== address) { + logger.throwArgumentError("bad address checksum", "address", address); + } + + return result; + } + + // Maybe ICAP? (we only support direct mode) + if (address.match(/^XE[0-9]{2}[0-9A-Za-z]{30,31}$/)) { + // It is an ICAP address with a bad checksum + if (address.substring(2, 4) !== ibanChecksum(address)) { + logger.throwArgumentError("bad icap checksum", "address", address); + } + + let result = fromBase36(address.substring(4)).toString(16); + while (result.length < 40) { result = "0" + result; } + return getChecksumAddress("0x" + result); + } + + return logger.throwArgumentError("invalid address", "address", address); +} + +export function getIcapAddress(address: string): string { + //let base36 = _base16To36(getAddress(address).substring(2)).toUpperCase(); + let base36 = BigInt(getAddress(address)).toString(36).toUpperCase(); + while (base36.length < 30) { base36 = "0" + base36; } + return "XE" + ibanChecksum("XE00" + base36) + base36; +} diff --git a/src.ts/address/checks.ts b/src.ts/address/checks.ts new file mode 100644 index 000000000..f85bec3a2 --- /dev/null +++ b/src.ts/address/checks.ts @@ -0,0 +1,54 @@ +import { logger } from "../utils/logger.js"; + +import { getAddress } from "./address.js"; + +import type { Addressable, AddressLike, NameResolver } from "./index.js"; + + +export function isAddressable(value: any): value is Addressable { + return (value && typeof(value.getAddress) === "function"); +} + +export function isAddress(value: any): boolean { + try { + getAddress(value); + return true; + } catch (error) { } + return false; +} + +async function checkAddress(target: any, promise: Promise): Promise { + const result = await promise; + if (result == null || result === "0x0000000000000000000000000000000000000000") { + if (typeof(target) === "string") { + return logger.throwError("unconfigured name", "UNCONFIGURED_NAME", { value: target }); + } + return logger.throwArgumentError("invalid AddressLike value; did not resolve to a value address", "target", target); + } + return getAddress(result); +} + +// Resolves an Ethereum address, ENS name or Addressable object, +// throwing if the result is null. +export function resolveAddress(target: AddressLike, resolver?: null | NameResolver): string | Promise { + + if (typeof(target) === "string") { + if (target.match(/^0x[0-9a-f]{40}$/i)) { return getAddress(target); } + + if (resolver == null) { + return logger.throwError("ENS resolution requires a provider", "UNSUPPORTED_OPERATION", { + operation: "resolveName", + }); + } + + return checkAddress(target, resolver.resolveName(target)); + + } else if (isAddressable(target)) { + return checkAddress(target, target.getAddress()); + + } else if (typeof(target.then) === "function") { + return checkAddress(target, target); + } + + return logger.throwArgumentError("unsupported addressable value", "target", target); +} diff --git a/src.ts/address/contract-address.ts b/src.ts/address/contract-address.ts new file mode 100644 index 000000000..0bd063da8 --- /dev/null +++ b/src.ts/address/contract-address.ts @@ -0,0 +1,40 @@ +import { keccak256 } from "../crypto/keccak.js"; +import { concat, dataSlice, encodeRlp, logger } from "../utils/index.js"; + +import { getAddress } from "./address.js"; + +import type { BigNumberish, BytesLike } from "../utils/index.js"; + + +// http://ethereum.stackexchange.com/questions/760/how-is-the-address-of-an-ethereum-contract-computed +export function getCreateAddress(tx: { from: string, nonce: BigNumberish }): string { + const from = getAddress(tx.from); + const nonce = logger.getBigInt(tx.nonce, "tx.nonce"); + + let nonceHex = nonce.toString(16); + if (nonceHex === "0") { + nonceHex = "0x"; + } else if (nonceHex.length % 2) { + nonceHex = "0x0" + nonceHex; + } else { + nonceHex = "0x" + nonceHex; + } + + return getAddress(dataSlice(keccak256(encodeRlp([ from, nonceHex ])), 12)); +} + +export function getCreate2Address(_from: string, _salt: BytesLike, _initCodeHash: BytesLike): string { + const from = getAddress(_from); + const salt = logger.getBytes(_salt, "salt"); + const initCodeHash = logger.getBytes(_initCodeHash, "initCodeHash"); + + if (salt.length !== 32) { + logger.throwArgumentError("salt must be 32 bytes", "salt", _salt); + } + + if (initCodeHash.length !== 32) { + logger.throwArgumentError("initCodeHash must be 32 bytes", "initCodeHash", _initCodeHash); + } + + return getAddress(dataSlice(keccak256(concat([ "0xff", from, salt, initCodeHash ])), 12)) +} diff --git a/src.ts/address/index.ts b/src.ts/address/index.ts new file mode 100644 index 000000000..06cbde843 --- /dev/null +++ b/src.ts/address/index.ts @@ -0,0 +1,16 @@ +export interface Addressable { + getAddress(): Promise; +} + +export type AddressLike = string | Promise | Addressable; + +export interface NameResolver { + resolveName(name: string): Promise; +} + +export { getAddress, getIcapAddress } from "./address.js"; + +export { getCreateAddress, getCreate2Address } from "./contract-address.js"; + + +export { isAddressable, isAddress, resolveAddress } from "./checks.js"; diff --git a/src.ts/constants/addresses.ts b/src.ts/constants/addresses.ts new file mode 100644 index 000000000..98baf82ee --- /dev/null +++ b/src.ts/constants/addresses.ts @@ -0,0 +1,6 @@ + +/** + * A constant for the zero address. + */ +export const ZeroAddress = "0x0000000000000000000000000000000000000000"; + diff --git a/src.ts/constants/hashes.ts b/src.ts/constants/hashes.ts new file mode 100644 index 000000000..9cd796191 --- /dev/null +++ b/src.ts/constants/hashes.ts @@ -0,0 +1,5 @@ +/** + * A constant for the zero hash. + */ +export const ZeroHash = "0x0000000000000000000000000000000000000000000000000000000000000000"; + diff --git a/src.ts/constants/index.ts b/src.ts/constants/index.ts new file mode 100644 index 000000000..d7eacf752 --- /dev/null +++ b/src.ts/constants/index.ts @@ -0,0 +1,14 @@ +export { ZeroAddress } from "./addresses.js"; +export { ZeroHash } from "./hashes.js"; +export { + NegativeOne, + Zero, + One, + Two, + N, + WeiPerEther, + MaxUint256, + MinInt256, + MaxInt256 +} from "./numbers.js"; +export { EtherSymbol, MessagePrefix } from "./strings.js"; diff --git a/src.ts/constants/numbers.ts b/src.ts/constants/numbers.ts new file mode 100644 index 000000000..c3a2e3c7b --- /dev/null +++ b/src.ts/constants/numbers.ts @@ -0,0 +1,57 @@ + +/** + * A constant for the BigInt of -1. + */ +const NegativeOne = BigInt(-1); + +/** + * A constant for the BigInt of 0. + */ +const Zero = BigInt(0); + +/** + * A constant for the BigInt of 1. + */ +const One = BigInt(1); + +/** + * A constant for the BigInt of 2. + */ +const Two = BigInt(2); + +/** + * A constant for the order N for the secp256k1 curve. + */ +const N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + +/** + * A constant for the number of wei in a single ether. + */ +const WeiPerEther = BigInt("1000000000000000000"); + +/** + * A constant for the maximum value for a ``uint256``. + */ +const MaxUint256 = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + +/** + * A constant for the minimum value for an ``int256``. + */ +const MinInt256 = BigInt("0x8000000000000000000000000000000000000000000000000000000000000000") * NegativeOne; + +/** + * A constant for the maximum value for an ``int256``. + */ +const MaxInt256 = BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + +export { + NegativeOne, + Zero, + One, + Two, + N, + WeiPerEther, + MaxUint256, + MinInt256, + MaxInt256, +}; diff --git a/src.ts/constants/strings.ts b/src.ts/constants/strings.ts new file mode 100644 index 000000000..8ec007228 --- /dev/null +++ b/src.ts/constants/strings.ts @@ -0,0 +1,9 @@ +// NFKC (composed) // (decomposed) + +/** + * A constant for the ether symbol (normalized using NFKC). + */ +export const EtherSymbol = "\u039e"; // "\uD835\uDF63"; + + +export const MessagePrefix = "\x19Ethereum Signed Message:\n"; diff --git a/src.ts/contract/contract.ts b/src.ts/contract/contract.ts new file mode 100644 index 000000000..b7dcd2f3e --- /dev/null +++ b/src.ts/contract/contract.ts @@ -0,0 +1,763 @@ +import { resolveAddress } from "../address/index.js"; +import { Interface, Typed } from "../abi/index.js"; +import { + defineProperties, isCallException, isHexString, resolveProperties, logger +} from "../utils/index.js"; +import { copyRequest, Log, TransactionResponse } from "../providers/index.js"; + +import { + ContractEventPayload, + ContractTransactionResponse, + EventLog +} from "./wrappers.js"; + +import type { EventFragment, FunctionFragment, InterfaceAbi, ParamType, Result } from "../abi/index.js"; +import type { Addressable } from "../address/index.js"; +import type { EventEmitterable, Listener } from "../utils/index.js"; +import type { + BlockTag, ContractRunner, Provider, TransactionRequest, TopicFilter +} from "../providers/index.js"; + +import type { + ContractEventName, + ContractInterface, + ContractMethodArgs, + BaseContractMethod, + ContractMethod, + ContractEventArgs, + ContractEvent, + ContractTransaction, + DeferredTopicFilter +} from "./types.js"; + +interface ContractRunnerCaller extends ContractRunner { + call: (tx: TransactionRequest) => Promise; +} + +interface ContractRunnerEstimater extends ContractRunner { + estimateGas: (tx: TransactionRequest) => Promise; +} + +interface ContractRunnerSender extends ContractRunner { + sendTransaction: (tx: TransactionRequest) => Promise; +} + +interface ContractRunnerResolver extends ContractRunner { + resolveName: (name: string | Addressable) => Promise; +} + +function canCall(value: any): value is ContractRunnerCaller { + return (value && typeof(value.call) === "function"); +} + +function canEstimate(value: any): value is ContractRunnerEstimater { + return (value && typeof(value.estimateGas) === "function"); +} + +function canResolve(value: any): value is ContractRunnerResolver { + return (value && typeof(value.resolveName) === "function"); +} + +function canSend(value: any): value is ContractRunnerSender { + return (value && typeof(value.sendTransaction) === "function"); +} + +function concisify(items: Array): Array { + items = Array.from((new Set(items)).values()) + items.sort(); + return items; +} + +class PreparedTopicFilter implements DeferredTopicFilter { + #filter: Promise; + readonly fragment!: EventFragment; + + constructor(contract: BaseContract, fragment: EventFragment, args: Array) { + defineProperties(this, { fragment }); + if (fragment.inputs.length < args.length) { + throw new Error("too many arguments"); + } + + // Recursively descend into args and resolve any addresses + const runner = getRunner(contract.runner, "resolveName"); + const resolver = canResolve(runner) ? runner: null; + this.#filter = (async function() { + const resolvedArgs = await Promise.all(fragment.inputs.map((param, index) => { + return param.walkAsync(args[index], (type, value) => { + if (type === "address") { return resolveAddress(value, resolver); } + return value; + }); + })); + + return contract.interface.encodeFilterTopics(fragment, resolvedArgs); + })(); + } + + getTopicFilter(): Promise { + return this.#filter; + } +} + + +// A = Arguments passed in as a tuple +// R = The result type of the call (i.e. if only one return type, +// the qualified type, otherwise Result) +// D = The type the default call will return (i.e. R for view/pure, +// TransactionResponse otherwise) +//export interface ContractMethod = Array, R = any, D extends R | ContractTransactionResponse = ContractTransactionResponse> { + +function _WrappedMethodBase(): new () => Function & BaseContractMethod { + return Function as any; +} + +function getRunner(value: any, feature: keyof ContractRunner): null | T { + if (value == null) { return null; } + if (typeof(value[feature]) === "function") { return value; } + if (value.provider && typeof(value.provider[feature]) === "function") { + return value.provider; + } + return null; +} + +function getProvider(value: null | ContractRunner): null | Provider { + if (value == null) { return null; } + return value.provider || null; +} + +export async function copyOverrides(arg: any): Promise> { + + // Create a shallow copy (we'll deep-ify anything needed during normalizing) + const overrides = copyRequest(Typed.dereference(arg, "overrides")); + + // Some sanity checking; these are what these methods adds + //if ((overrides).to) { + if (overrides.to) { + logger.throwArgumentError("cannot override to", "overrides.to", overrides.to); + } else if (overrides.data) { + logger.throwArgumentError("cannot override data", "overrides.data", overrides.data); + } + + // Resolve any from + if (overrides.from) { + overrides.from = await resolveAddress(overrides.from); + } + + return overrides; +} + +export async function resolveArgs(_runner: null | ContractRunner, inputs: ReadonlyArray, args: Array): Promise> { + // Recursively descend into args and resolve any addresses + const runner = getRunner(_runner, "resolveName"); + const resolver = canResolve(runner) ? runner: null; + return await Promise.all(inputs.map((param, index) => { + return param.walkAsync(args[index], (type, value) => { + if (type === "address") { return resolveAddress(value, resolver); } + return value; + }); + })); +} + +class WrappedMethod = Array, R = any, D extends R | ContractTransactionResponse = ContractTransactionResponse> + extends _WrappedMethodBase() implements BaseContractMethod { + + readonly name: string = ""; // Investigate! + readonly _contract!: BaseContract; + readonly _key!: string; + + constructor (contract: BaseContract, key: string) { + super(); + + defineProperties(this, { + name: contract.interface.getFunctionName(key), + _contract: contract, _key: key + }); + + const proxy = new Proxy(this, { + // Perform the default operation for this fragment type + apply: async (target, thisArg, args: ContractMethodArgs) => { + const fragment = target.getFragment(...args); + if (fragment.constant) { return await target.staticCall(...args); } + return await target.send(...args); + }, + }); + + return proxy; + } + + // Only works on non-ambiguous keys (refined fragment is always non-ambiguous) + get fragment(): FunctionFragment { + return this._contract.interface.getFunction(this._key); + } + + getFragment(...args: ContractMethodArgs): FunctionFragment { + return this._contract.interface.getFunction(this._key, args); + } + + async populateTransaction(...args: ContractMethodArgs): Promise { + const fragment = this.getFragment(...args); + + // If an overrides was passed in, copy it and normalize the values + let overrides: Omit = { }; + if (fragment.inputs.length + 1 === args.length) { + overrides = await copyOverrides(args.pop()); + } + + if (fragment.inputs.length !== args.length) { + throw new Error("internal error: fragment inputs doesn't match arguments; should not happen"); + } + + const resolvedArgs = await resolveArgs(this._contract.runner, fragment.inputs, args); + + return Object.assign({ }, overrides, await resolveProperties({ + to: this._contract.getAddress(), + data: this._contract.interface.encodeFunctionData(fragment, resolvedArgs) + })); + } + + async staticCall(...args: ContractMethodArgs): Promise { + const result = await this.staticCallResult(...args); + if (result.length === 1) { return result[0]; } + return result; + } + + async send(...args: ContractMethodArgs): Promise { + const runner = this._contract.runner; + if (!canSend(runner)) { + return logger.throwError("contract runner does not support sending transactions", "UNSUPPORTED_OPERATION", { + operation: "sendTransaction" + }); + } + const tx = await runner.sendTransaction(await this.populateTransaction(...args)); + const provider = getProvider(this._contract.runner); + return new ContractTransactionResponse(this._contract.interface, provider, tx); + } + + async estimateGas(...args: ContractMethodArgs): Promise { + const runner = getRunner(this._contract.runner, "estimateGas"); + if (!canEstimate(runner)) { + return logger.throwError("contract runner does not support gas estimation", "UNSUPPORTED_OPERATION", { + operation: "estimateGas" + }); + } + return await runner.estimateGas(await this.populateTransaction(...args)); + } + + async staticCallResult(...args: ContractMethodArgs): Promise { + const runner = getRunner(this._contract.runner, "call"); + if (!canCall(runner)) { + return logger.throwError("contract runner does not support calling", "UNSUPPORTED_OPERATION", { + operation: "call" + }); + } + + const fragment = this.getFragment(...args); + const tx = await this.populateTransaction(...args); + + let result = "0x"; + try { + result = await runner.call(tx); + } catch (error: any) { + if (isCallException(error)) { + throw this._contract.interface.makeError(fragment, error.data, tx); + } + throw error; + } + + return this._contract.interface.decodeFunctionResult(fragment, result); + } +} + +function _WrappedEventBase(): new () => Function & ContractEvent { + return Function as any; +} + +class WrappedEvent = Array> extends _WrappedEventBase() implements ContractEvent { + readonly name: string = ""; // @TODO: investigate + + readonly _contract!: BaseContract; + readonly _key!: string; + + constructor (contract: BaseContract, key: string) { + super(); + + defineProperties(this, { + name: contract.interface.getEventName(key), + _contract: contract, _key: key + }); + + return new Proxy(this, { + // Perform the default operation for this fragment type + apply: async (target, thisArg, args: ContractEventArgs) => { + return new PreparedTopicFilter(contract, target.getFragment(...args), args); + }, + }); + } + + // Only works on non-ambiguous keys + get fragment(): EventFragment { + return this._contract.interface.getEvent(this._key); + } + + getFragment(...args: ContractEventArgs): EventFragment { + return this._contract.interface.getEvent(this._key, args); + } +}; + +type Sub = { + tag: string; + listeners: Array<{ listener: Listener, once: boolean }>, + start: () => void; + stop: () => void; +}; + + +// The combination of TypeScrype, Private Fields and Proxies makes +// the world go boom; so we hide variables with some trickery keeping +// a symbol attached to each BaseContract which its sub-class (even +// via a Proxy) can reach and use to look up its internal values. + +const internal = Symbol.for("_ethersInternal_contract"); +type Internal = { + addrPromise: Promise; + addr: null | string; + + deployTx: null | ContractTransactionResponse; + + subs: Map; +}; + +const internalValues: WeakMap = new WeakMap(); + +function setInternal(contract: BaseContract, values: Internal): void { + internalValues.set(contract[internal], values); +} + +function getInternal(contract: BaseContract): Internal { + return internalValues.get(contract[internal]) as Internal; +} + +function isDeferred(value: any): value is DeferredTopicFilter { + return (value && typeof(value) === "object" && ("getTopicFilter" in value) && + (typeof(value.getTopicFilter) === "function") && value.fragment); +} + +async function getSubTag(contract: BaseContract, event: ContractEventName): Promise<{ tag: string, fragment: EventFragment, topics: TopicFilter }> { + let fragment: EventFragment; + let topics: Array>; + + if (Array.isArray(event)) { + // Topics; e.g. `[ "0x1234...89ab" ]` + fragment = contract.interface.getEvent(event[0] as string); + topics = event; + + } else if (typeof(event) === "string") { + // Event name (name or signature); `"Transfer"` + fragment = contract.interface.getEvent(event); + topics = [ contract.interface.getEventTopic(fragment) ]; + + } else if (isDeferred(event)) { + // Deferred Topic Filter; e.g. `contract.filter.Transfer(from)` + fragment = event.fragment; + topics = await event.getTopicFilter(); + + } else if ("fragment" in event) { + // ContractEvent; e.g. `contract.filter.Transfer` + fragment = event.fragment; + topics = [ contract.interface.getEventTopic(fragment) ]; + + } else { + console.log(event); + throw new Error("TODO"); + } + + // Normalize topics and sort TopicSets + topics = topics.map((t) => { + if (t == null) { return null; } + if (Array.isArray(t)) { + return concisify(t.map((t) => t.toLowerCase())); + } + return t.toLowerCase(); + }); + + const tag = topics.map((t) => { + if (t == null) { return "null"; } + if (Array.isArray(t)) { return t.join("|"); } + return t; + }).join("&"); + + return { fragment, tag, topics } +} + +async function hasSub(contract: BaseContract, event: ContractEventName): Promise { + const { subs } = getInternal(contract); + return subs.get((await getSubTag(contract, event)).tag) || null; +} + +async function getSub(contract: BaseContract, event: ContractEventName): Promise { + // Make sure our runner can actually subscribe to events + const provider = getProvider(contract.runner); + if (!provider) { + return logger.throwError("contract runner does not support subscribing", "UNSUPPORTED_OPERATION", { + operation: "on" + }); + } + + const { fragment, tag, topics } = await getSubTag(contract, event); + + const { addr, subs } = getInternal(contract); + + let sub = subs.get(tag); + if (!sub) { + const address: string | Addressable = (addr ? addr: contract); + const filter = { address, topics }; + const listener = (log: Log) => { + const payload = new ContractEventPayload(contract, null, event, fragment, log); + emit(contract, event, payload.args, payload); + }; + + let started = false; + const start = () => { + if (started) { return; } + provider.on(filter, listener); + started = true; + }; + const stop = () => { + if (!started) { return; } + provider.off(filter, listener); + started = false; + }; + sub = { tag, listeners: [ ], start, stop }; + subs.set(tag, sub); + } + return sub; +} + +// We use this to ensure one emit resolves before firing the next to +// ensure correct ordering (note this cannot throw and just adds the +// notice to the event queu using setTimeout). +let lastEmit: Promise = Promise.resolve(); + +async function _emit(contract: BaseContract, event: ContractEventName, args: Array, payload: null | ContractEventPayload): Promise { + await lastEmit; + + const sub = await hasSub(contract, event); + if (!sub) { return false; } + + const count = sub.listeners.length; + sub.listeners = sub.listeners.filter(({ listener, once }) => { + const passArgs = args.slice(); + if (payload) { + passArgs.push(new ContractEventPayload(contract, (once ? null: listener), + event, payload.fragment, payload.log)); + } + try { + listener.call(contract, ...passArgs); + } catch (error) { } + return !once; + }); + return (count > 0); +} + +async function emit(contract: BaseContract, event: ContractEventName, args: Array, payload: null | ContractEventPayload): Promise { + try { + await lastEmit; + } catch (error) { } + + const resultPromise = _emit(contract, event, args, payload); + lastEmit = resultPromise; + return await resultPromise; +} + +const passProperties = [ "then" ]; +export class BaseContract implements Addressable, EventEmitterable { + readonly target!: string | Addressable; + readonly interface!: Interface; + readonly runner!: null | ContractRunner; + + readonly filters!: Record; + + readonly [internal]: any; + + constructor(target: string | Addressable, abi: Interface | InterfaceAbi, runner: null | ContractRunner = null, _deployTx?: null | TransactionResponse) { + const iface = Interface.from(abi); + defineProperties(this, { target, runner, interface: iface }); + + Object.defineProperty(this, internal, { value: { } }); + + let addrPromise; + let addr = null; + + let deployTx: null | ContractTransactionResponse = null; + if (_deployTx) { + const provider = getProvider(runner); + deployTx = new ContractTransactionResponse(this.interface, provider, _deployTx); + } + + let subs = new Map(); + + // Resolve the target as the address + if (typeof(target) === "string") { + if (isHexString(target)) { + addr = target; + addrPromise = Promise.resolve(target); + + } else { + const resolver = getRunner(runner, "resolveName"); + if (!canResolve(resolver)) { + throw logger.makeError("contract runner does not support name resolution", "UNSUPPORTED_OPERATION", { + operation: "resolveName" + }); + } + + addrPromise = resolver.resolveName(target).then((addr) => { + if (addr == null) { throw new Error("TODO"); } + getInternal(this).addr = addr; + return addr; + }); + } + } else { + addrPromise = target.getAddress().then((addr) => { + if (addr == null) { throw new Error("TODO"); } + getInternal(this).addr = addr; + return addr; + }); + } + + // Set our private values + setInternal(this, { addrPromise, addr, deployTx, subs }); + + // Add the event filters + const filters = new Proxy({ }, { + get: (target, _prop, receiver) => { + // Pass important checks (like `then` for Promise) through + if (passProperties.indexOf(_prop) >= 0) { + return Reflect.get(target, _prop, receiver); + } + + const prop = String(_prop); + + const result = this.getEvent(prop); + if (result) { return result; } + + throw new Error(`unknown contract event: ${ prop }`); + } + }); + defineProperties(this, { filters }); + + // Return a Proxy that will respond to functions + return new Proxy(this, { + get: (target, _prop, receiver) => { + if (_prop in target || passProperties.indexOf(_prop) >= 0) { + return Reflect.get(target, _prop, receiver); + } + + const prop = String(_prop); + + const result = target.getFunction(prop); + if (result) { return result; } + + throw new Error(`unknown contract method: ${ prop }`); + } + }); + } + + async getAddress(): Promise { return await getInternal(this).addrPromise; } + + async getDeployedCode(): Promise { + const provider = getProvider(this.runner); + if (!provider) { + return logger.throwError("runner does not support .provider", "UNSUPPORTED_OPERATION", { + operation: "getDeployedCode" + }); + } + + const code = await provider.getCode(await this.getAddress()); + if (code === "0x") { return null; } + return code; + } + + async waitForDeployment(): Promise { + // We have the deployement transaction; just use that (throws if deployement fails) + const deployTx = this.deploymentTransaction(); + if (deployTx) { + await deployTx.wait(); + return this; + } + + // Check for code + const code = await this.getDeployedCode(); + if (code != null) { return this; } + + // Make sure we can subscribe to a provider event + const provider = getProvider(this.runner); + if (provider == null) { + return logger.throwError("contract runner does not support .provider", "UNSUPPORTED_OPERATION", { + operation: "waitForDeployment" + }); + } + + return new Promise((resolve, reject) => { + const checkCode = async () => { + try { + const code = await this.getDeployedCode(); + if (code != null) { return resolve(this); } + provider.once("block", checkCode); + } catch (error) { + reject(error); + } + }; + checkCode(); + }); + } + + deploymentTransaction(): null | ContractTransactionResponse { + return getInternal(this).deployTx; + } + + getFunction(key: string | FunctionFragment): T { + if (typeof(key) !== "string") { key = key.format(); } + return (new WrappedMethod(this, key)); + } + + getEvent(key: string | EventFragment): ContractEvent { + if (typeof(key) !== "string") { key = key.format(); } + return (new WrappedEvent(this, key)); + } + + async queryTransaction(hash: string): Promise> { + // Is this useful? + throw new Error("@TODO"); + } + + async queryFilter(event: ContractEventName, fromBlock: BlockTag = 0, toBlock: BlockTag = "latest"): Promise> { + const { addr, addrPromise } = getInternal(this); + const address = (addr ? addr: (await addrPromise)); + const { fragment, topics } = await getSubTag(this, event); + const filter = { address, topics, fromBlock, toBlock }; + + const provider = getProvider(this.runner); + if (!provider) { + return logger.throwError("contract runner does not have a provider", "UNSUPPORTED_OPERATION", { + operation: "queryFilter" + }); + } + + return (await provider.getLogs(filter)).map((log) => { + return new EventLog(log, this.interface, fragment); + }); + } + + async on(event: ContractEventName, listener: Listener): Promise { + const sub = await getSub(this, event); + sub.listeners.push({ listener, once: false }); + sub.start(); + return this; + } + + async once(event: ContractEventName, listener: Listener): Promise { + const sub = await getSub(this, event); + sub.listeners.push({ listener, once: true }); + sub.start(); + return this; + } + + async emit(event: ContractEventName, ...args: Array): Promise { + return await emit(this, event, args, null); + } + + async listenerCount(event?: ContractEventName): Promise { + if (event) { + const sub = await hasSub(this, event); + if (!sub) { return 0; } + return sub.listeners.length; + } + + const { subs } = getInternal(this); + + let total = 0; + for (const { listeners } of subs.values()) { + total += listeners.length; + } + return total; + } + + async listeners(event?: ContractEventName): Promise> { + if (event) { + const sub = await hasSub(this, event); + if (!sub) { return [ ]; } + return sub.listeners.map(({ listener }) => listener); + } + + const { subs } = getInternal(this); + + let result: Array = [ ]; + for (const { listeners } of subs.values()) { + result = result.concat(listeners.map(({ listener }) => listener)); + } + return result; + } + + async off(event: ContractEventName, listener?: Listener): Promise { + const sub = await hasSub(this, event); + if (!sub) { return this; } + + if (listener) { + const index = sub.listeners.map(({ listener }) => listener).indexOf(listener); + if (index >= 0) { sub.listeners.splice(index, 1); } + } + + if (listener == null || sub.listeners.length === 0) { + sub.stop(); + getInternal(this).subs.delete(sub.tag); + } + + return this; + } + + async removeAllListeners(event?: ContractEventName): Promise { + if (event) { + const sub = await hasSub(this, event); + if (!sub) { return this; } + sub.stop(); + getInternal(this).subs.delete(sub.tag); + } else { + const { subs } = getInternal(this); + for (const { tag, stop } of subs.values()) { + stop(); + subs.delete(tag); + } + } + + return this; + } + + // Alias for "on" + async addListener(event: ContractEventName, listener: Listener): Promise { + return await this.on(event, listener); + } + + // Alias for "off" + async removeListener(event: ContractEventName, listener: Listener): Promise { + return await this.off(event, listener); + } + + static buildClass(abi: InterfaceAbi): new (target: string, runner?: null | ContractRunner) => BaseContract & Omit { + class CustomContract extends BaseContract { + constructor(address: string, runner: null | ContractRunner = null) { + super(address, abi, runner); + } + } + return CustomContract as any; + }; + + static from(target: string, abi: InterfaceAbi, runner: null | ContractRunner = null): BaseContract & Omit { + const contract = new this(target, abi, runner); + return contract as any; + } +} + +function _ContractBase(): new (target: string, abi: InterfaceAbi, runner?: null | ContractRunner) => BaseContract & Omit { + return BaseContract as any; +} + +export class Contract extends _ContractBase() { } diff --git a/src.ts/contract/factory.ts b/src.ts/contract/factory.ts new file mode 100644 index 000000000..3e766ec08 --- /dev/null +++ b/src.ts/contract/factory.ts @@ -0,0 +1,97 @@ + +import { Interface } from "../abi/index.js"; +import { getCreateAddress } from "../address/index.js"; +import { concat, defineProperties, hexlify, logger } from "../utils/index.js"; + +import { BaseContract, copyOverrides, resolveArgs } from "./contract.js"; + +import type { InterfaceAbi } from "../abi/index.js"; +import type { ContractRunner } from "../providers/index.js"; +import type { BytesLike } from "../utils/index.js"; + +import type { + ContractInterface, ContractMethodArgs, ContractDeployTransaction, +} from "./types.js"; +import type { ContractTransactionResponse } from "./wrappers.js"; + + +// A = Arguments to the constructor +// I = Interface of deployed contracts +export class ContractFactory = Array, I = BaseContract> { + readonly interface!: Interface; + readonly bytecode!: string; + readonly runner!: null | ContractRunner; + + constructor(abi: Interface | InterfaceAbi, bytecode: BytesLike | { object: string }, runner?: null | ContractRunner) { + const iface = Interface.from(abi); + + // Dereference Solidity bytecode objects and allow a missing `0x`-prefix + if (bytecode instanceof Uint8Array) { + bytecode = hexlify(logger.getBytes(bytecode)); + } else { + if (typeof(bytecode) === "object") { bytecode = bytecode.object; } + if (bytecode.substring(0, 2) !== "0x") { bytecode = "0x" + bytecode; } + bytecode = hexlify(logger.getBytes(bytecode)); + } + + defineProperties(this, { + bytecode, interface: iface, runner: (runner || null) + }); + } + + async getDeployTransaction(...args: ContractMethodArgs): Promise { + let overrides: Omit = { }; + + const fragment = this.interface.deploy; + + if (fragment.inputs.length + 1 === args.length) { + overrides = await copyOverrides(args.pop()); + } + + if (fragment.inputs.length !== args.length) { + throw new Error("incorrect number of arguments to constructor"); + } + + const resolvedArgs = await resolveArgs(this.runner, fragment.inputs, args); + + const data = concat([ this.bytecode, this.interface.encodeDeploy(resolvedArgs) ]); + return Object.assign({ }, overrides, { data }); + } + + async deploy(...args: ContractMethodArgs): Promise> { + const tx = await this.getDeployTransaction(...args); + + if (!this.runner || typeof(this.runner.sendTransaction) !== "function") { + return logger.throwError("factory runner does not support sending transactions", "UNSUPPORTED_OPERATION", { + operation: "sendTransaction" + }); + } + + const sentTx = await this.runner.sendTransaction(tx); + const address = getCreateAddress(sentTx); + return new (BaseContract)(address, this.interface, this.runner, sentTx); + } + + connect(runner: null | ContractRunner): ContractFactory { + return new ContractFactory(this.interface, this.bytecode, runner); + } + + static fromSolidity = Array, I = ContractInterface>(output: any, runner?: ContractRunner): ContractFactory { + if (output == null) { + logger.throwArgumentError("bad compiler output", "output", output); + } + + if (typeof(output) === "string") { output = JSON.parse(output); } + + const abi = output.abi; + + let bytecode = ""; + if (output.bytecode) { + bytecode = output.bytecode; + } else if (output.evm && output.evm.bytecode) { + bytecode = output.evm.bytecode; + } + + return new this(abi, bytecode, runner); + } +} diff --git a/src.ts/contract/index.ts b/src.ts/contract/index.ts new file mode 100644 index 000000000..4d33d011a --- /dev/null +++ b/src.ts/contract/index.ts @@ -0,0 +1,19 @@ + +export { + BaseContract, Contract +} from "./contract.js"; + +export { + ContractFactory +} from "./factory.js"; + +export { + ContractEventPayload, ContractTransactionReceipt, ContractTransactionResponse, + EventLog +} from "./wrappers.js"; + +export type { + ConstantContractMethod, ContractEvent, ContractEventArgs, ContractEventName, + ContractInterface, ContractMethod, ContractMethodArgs, ContractTransaction, + DeferredTopicFilter, Overrides +} from "./types.js"; diff --git a/src.ts/contract/types.ts b/src.ts/contract/types.ts new file mode 100644 index 000000000..e514cdf83 --- /dev/null +++ b/src.ts/contract/types.ts @@ -0,0 +1,83 @@ +import type { + EventFragment, FunctionFragment, Result, Typed +} from "../abi/index.js"; +import type { + CallRequest, PreparedRequest, TopicFilter +} from "../providers/index.js"; + +import type { ContractTransactionResponse } from "./wrappers.js"; + + +// The types of events a Contract can listen for +export type ContractEventName = string | ContractEvent | TopicFilter; + +export interface ContractInterface { + [ name: string ]: BaseContractMethod; +}; + +export interface DeferredTopicFilter { + getTopicFilter(): Promise; + fragment: EventFragment; +} + +export interface ContractTransaction extends PreparedRequest { + // These are populated by contract methods and cannot bu null + to: string; + data: string; +} + +// Deployment Transactions have no `to` +export interface ContractDeployTransaction extends Omit { } + +// Overrides; cannot override `to` or `data` as Contract populates these +export interface Overrides extends Omit { }; + + +// Arguments for methods; with an optional (n+1)th Override +export type PostfixOverrides> = A | [ ...A, Overrides ]; +export type ContractMethodArgs> = PostfixOverrides<{ [ I in keyof A ]-?: A[I] | Typed }>; + +// A = Arguments passed in as a tuple +// R = The result type of the call (i.e. if only one return type, +// the qualified type, otherwise Result) +// D = The type the default call will return (i.e. R for view/pure, +// TransactionResponse otherwise) +export interface BaseContractMethod = Array, R = any, D extends R | ContractTransactionResponse = R | ContractTransactionResponse> { + (...args: ContractMethodArgs): Promise; + + name: string; + + fragment: FunctionFragment; + + getFragment(...args: ContractMethodArgs): FunctionFragment; + + populateTransaction(...args: ContractMethodArgs): Promise; + staticCall(...args: ContractMethodArgs): Promise; + send(...args: ContractMethodArgs): Promise; + estimateGas(...args: ContractMethodArgs): Promise; + staticCallResult(...args: ContractMethodArgs): Promise; +} + +export interface ContractMethod< + A extends Array = Array, + R = any, + D extends R | ContractTransactionResponse = R | ContractTransactionResponse +> extends BaseContractMethod { } + +export interface ConstantContractMethod< + A extends Array, + R = any +> extends ContractMethod { } + + +// Arguments for events; with each element optional and/or nullable +export type ContractEventArgs> = { [ I in keyof A ]?: A[I] | Typed | null }; + +export interface ContractEvent = Array> { + (...args: ContractEventArgs): DeferredTopicFilter; + + name: string; + + fragment: EventFragment; + getFragment(...args: ContractEventArgs): EventFragment; +}; diff --git a/src.ts/contract/wrappers.ts b/src.ts/contract/wrappers.ts new file mode 100644 index 000000000..a9b46c3ee --- /dev/null +++ b/src.ts/contract/wrappers.ts @@ -0,0 +1,99 @@ +import { + Block, Log, TransactionReceipt, TransactionResponse +} from "../providers/index.js"; +import { defineProperties, EventPayload } from "../utils/index.js"; + +import type { EventFragment, Interface, Result } from "../abi/index.js"; +import type { Listener } from "../utils/index.js"; +import type { + Provider +} from "../providers/index.js"; + +import type { BaseContract } from "./contract.js"; +import type { ContractEventName } from "./types.js"; + + +export class EventLog extends Log { + readonly interface!: Interface; + readonly fragment!: EventFragment; + readonly args!: Result; + + constructor(log: Log, iface: Interface, fragment: EventFragment) { + super(log, log.provider); + const args = iface.decodeEventLog(fragment, log.data, log.topics); + defineProperties(this, { args, fragment, interface: iface }); + } + + get eventName(): string { return this.fragment.name; } + get eventSignature(): string { return this.fragment.format(); } +} + +export class ContractTransactionReceipt extends TransactionReceipt { + readonly #interface: Interface; + + constructor(iface: Interface, provider: null | Provider, tx: TransactionReceipt) { + super(tx, provider); + this.#interface = iface; + } + + get logs(): Array { + return super.logs.map((log) => { + const fragment = log.topics.length ? this.#interface.getEvent(log.topics[0]): null; + if (fragment) { + return new EventLog(log, this.#interface, fragment) + } else { + return log; + } + }); + } + +} + +export class ContractTransactionResponse extends TransactionResponse { + readonly #interface: Interface; + + constructor(iface: Interface, provider: null | Provider, tx: TransactionResponse) { + super(tx, provider); + this.#interface = iface; + } + + async wait(confirms?: number): Promise { + const receipt = await super.wait(); + if (receipt == null) { return null; } + return new ContractTransactionReceipt(this.#interface, this.provider, receipt); + } +} + +export class ContractEventPayload extends EventPayload { + + readonly fragment!: EventFragment; + readonly log!: EventLog; + readonly args!: Result; + + constructor(contract: BaseContract, listener: null | Listener, filter: ContractEventName, fragment: EventFragment, _log: Log) { + super(contract, listener, filter); + const log = new EventLog(_log, contract.interface, fragment); + const args = contract.interface.decodeEventLog(fragment, log.data, log.topics); + defineProperties(this, { args, fragment, log }); + } + + get eventName(): string { + return this.fragment.name; + } + + get eventSignature(): string { + return this.fragment.format(); + } + + async getBlock(): Promise> { + return await this.log.getBlock(); + } + + async getTransaction(): Promise { + return await this.log.getTransaction(); + } + + async getTransactionReceipt(): Promise { + return await this.log.getTransactionReceipt(); + } +} diff --git a/src.ts/crypto/crypto-browser.ts b/src.ts/crypto/crypto-browser.ts new file mode 100644 index 000000000..cc0d074b9 --- /dev/null +++ b/src.ts/crypto/crypto-browser.ts @@ -0,0 +1,73 @@ +/* Browser Crypto Shims */ + +import { hmac } from "@noble/hashes/hmac"; +import { pbkdf2 } from "@noble/hashes/pbkdf2"; +import { sha256 } from "@noble/hashes/sha256"; +import { sha512 } from "@noble/hashes/sha512"; + +import { logger } from "../utils/logger.js"; + + +declare global { + interface Window { } + + const window: Window; + const self: Window; +} + + +function getGlobal(): any { + if (typeof self !== 'undefined') { return self; } + if (typeof window !== 'undefined') { return window; } + if (typeof global !== 'undefined') { return global; } + throw new Error('unable to locate global object'); +}; + +const anyGlobal = getGlobal(); +const crypto: any = anyGlobal.crypto || anyGlobal.msCrypto; + + +export interface CryptoHasher { + update(data: Uint8Array): CryptoHasher; + digest(): Uint8Array; +} + +export function createHash(algo: string): CryptoHasher { + switch (algo) { + case "sha256": return sha256.create(); + case "sha512": return sha512.create(); + } + return logger.throwArgumentError("invalid hashing algorithm name", "algorithm", algo); +} + +export function createHmac(_algo: string, key: Uint8Array): CryptoHasher { + const algo = ({ sha256, sha512 }[_algo]); + if (algo == null) { + return logger.throwArgumentError("invalid hmac algorithm", "algorithm", _algo); + } + return hmac.create(algo, key); +} + +export function pbkdf2Sync(password: Uint8Array, salt: Uint8Array, iterations: number, keylen: number, _algo: "sha256" | "sha512"): Uint8Array { + const algo = ({ sha256, sha512 }[_algo]); + if (algo == null) { + return logger.throwArgumentError("invalid pbkdf2 algorithm", "algorithm", _algo); + } + return pbkdf2(algo, password, salt, { c: iterations, dkLen: keylen }); +} + +export function randomBytes(length: number): Uint8Array { + if (crypto == null) { + return logger.throwError("platform does not support secure random numbers", "UNSUPPORTED_OPERATION", { + operation: "randomBytes" + }); + } + + if (!Number.isInteger(length) || length <= 0 || length > 1024) { + logger.throwArgumentError("invalid length", "length", length); + } + + const result = new Uint8Array(length); + crypto.getRandomValues(result); + return result; +} diff --git a/src.ts/crypto/crypto.ts b/src.ts/crypto/crypto.ts new file mode 100644 index 000000000..142ee3f00 --- /dev/null +++ b/src.ts/crypto/crypto.ts @@ -0,0 +1,4 @@ + +export { + createHash, createHmac, pbkdf2Sync, randomBytes +} from "crypto"; diff --git a/src.ts/crypto/hmac.ts b/src.ts/crypto/hmac.ts new file mode 100644 index 000000000..f37b291aa --- /dev/null +++ b/src.ts/crypto/hmac.ts @@ -0,0 +1,26 @@ +import { createHmac } from "./crypto.js"; +import { hexlify, logger } from "../utils/index.js"; + +import type { BytesLike } from "../utils/index.js"; + + +let locked = false; + +const _computeHmac = function(algorithm: "sha256" | "sha512", key: Uint8Array, data: Uint8Array): BytesLike { + return "0x" + createHmac(algorithm, key).update(data).digest("hex"); +} + +let __computeHmac = _computeHmac; + +export function computeHmac(algorithm: "sha256" | "sha512", _key: BytesLike, _data: BytesLike): string { + const key = logger.getBytes(_key, "key"); + const data = logger.getBytes(_data, "data"); + return hexlify(__computeHmac(algorithm, key, data)); +} +computeHmac._ = _computeHmac; +computeHmac.lock = function() { locked = true; } +computeHmac.register = function(func: (algorithm: "sha256" | "sha512", key: Uint8Array, data: Uint8Array) => BytesLike) { + if (locked) { throw new Error("computeHmac is locked"); } + __computeHmac = func; +} +Object.freeze(computeHmac); diff --git a/src.ts/crypto/index.ts b/src.ts/crypto/index.ts new file mode 100644 index 000000000..2ce7efbf2 --- /dev/null +++ b/src.ts/crypto/index.ts @@ -0,0 +1,45 @@ +// We import all these so we can export lock() +import { computeHmac } from "./hmac.js"; +import { keccak256 } from "./keccak.js"; +import { ripemd160 } from "./ripemd160.js"; +import { pbkdf2 } from "./pbkdf2.js"; +import { randomBytes } from "./random.js"; +import { scrypt, scryptSync } from "./scrypt.js"; +import { sha256, sha512 } from "./sha2.js"; + +export { + computeHmac, + + randomBytes, + + keccak256, + ripemd160, + sha256, sha512, + + pbkdf2, + scrypt, scryptSync +}; + +export { SigningKey } from "./signing-key.js"; +export { Signature } from "./signature.js"; + +function lock(): void { + computeHmac.lock(); + keccak256.lock(); + pbkdf2.lock(); + randomBytes.lock(); + ripemd160.lock(); + scrypt.lock(); + scryptSync.lock(); + sha256.lock(); + sha512.lock(); +} + +export { lock }; + +///////////////////////////// +// Types + +export type { ProgressCallback } from "./scrypt.js"; + +export type { SignatureLike } from "./signature.js"; diff --git a/src.ts/crypto/keccak.ts b/src.ts/crypto/keccak.ts new file mode 100644 index 000000000..35e38e64b --- /dev/null +++ b/src.ts/crypto/keccak.ts @@ -0,0 +1,26 @@ +import { keccak_256 } from "@noble/hashes/sha3"; + +import { hexlify, logger } from "../utils/index.js"; + +import type { BytesLike } from "../utils/index.js"; + + +let locked = false; + +const _keccak256 = function(data: Uint8Array): Uint8Array { + return keccak_256(data); +} + +let __keccak256: (data: Uint8Array) => BytesLike = _keccak256; + +export function keccak256(_data: BytesLike): string { + const data = logger.getBytes(_data, "data"); + return hexlify(__keccak256(data)); +} +keccak256._ = _keccak256; +keccak256.lock = function(): void { locked = true; } +keccak256.register = function(func: (data: Uint8Array) => BytesLike) { + if (locked) { throw new TypeError("keccak256 is locked"); } + __keccak256 = func; +} +Object.freeze(keccak256); diff --git a/src.ts/crypto/pbkdf2.ts b/src.ts/crypto/pbkdf2.ts new file mode 100644 index 000000000..041d9f954 --- /dev/null +++ b/src.ts/crypto/pbkdf2.ts @@ -0,0 +1,27 @@ +import { pbkdf2Sync } from "./crypto.js"; + +import { hexlify, logger } from "../utils/index.js"; + +import type { BytesLike } from "../utils/index.js"; + + +let locked = false; + +const _pbkdf2 = function(password: Uint8Array, salt: Uint8Array, iterations: number, keylen: number, algo: "sha256" | "sha512"): BytesLike { + return pbkdf2Sync(password, salt, iterations, keylen, algo); +} + +let __pbkdf2 = _pbkdf2; + +export function pbkdf2(_password: BytesLike, _salt: BytesLike, iterations: number, keylen: number, algo: "sha256" | "sha512"): string { + const password = logger.getBytes(_password, "password"); + const salt = logger.getBytes(_salt, "salt"); + return hexlify(__pbkdf2(password, salt, iterations, keylen, algo)); +} +pbkdf2._ = _pbkdf2; +pbkdf2.lock = function(): void { locked = true; } +pbkdf2.register = function(func: (password: Uint8Array, salt: Uint8Array, iterations: number, keylen: number, algo: "sha256" | "sha512") => BytesLike) { + if (locked) { throw new Error("pbkdf2 is locked"); } + __pbkdf2 = func; +} +Object.freeze(pbkdf2); diff --git a/src.ts/crypto/random.ts b/src.ts/crypto/random.ts new file mode 100644 index 000000000..72fb619d9 --- /dev/null +++ b/src.ts/crypto/random.ts @@ -0,0 +1,21 @@ +import { randomBytes as crypto_random } from "./crypto.js"; + +let locked = false; + +const _randomBytes = function(length: number): Uint8Array { + return new Uint8Array(crypto_random(length)); +} + +let __randomBytes = _randomBytes; + +export function randomBytes(length: number): Uint8Array { + return __randomBytes(length); +} + +randomBytes._ = _randomBytes; +randomBytes.lock = function(): void { locked = true; } +randomBytes.register = function(func: (length: number) => Uint8Array) { + if (locked) { throw new Error("random is locked"); } + __randomBytes = func; +} +Object.freeze(randomBytes); diff --git a/src.ts/crypto/ripemd160.ts b/src.ts/crypto/ripemd160.ts new file mode 100644 index 000000000..55ffcfc65 --- /dev/null +++ b/src.ts/crypto/ripemd160.ts @@ -0,0 +1,26 @@ +import { ripemd160 as noble_ripemd160 } from "@noble/hashes/ripemd160"; + +import { hexlify, logger } from "../utils/index.js"; + +import type { BytesLike } from "../utils/index.js"; + + +let locked = false; + +const _ripemd160 = function(data: Uint8Array): Uint8Array { + return noble_ripemd160(data); +} + +let __ripemd160: (data: Uint8Array) => BytesLike = _ripemd160; + +export function ripemd160(_data: BytesLike): string { + const data = logger.getBytes(_data, "data"); + return hexlify(__ripemd160(data)); +} +ripemd160._ = _ripemd160; +ripemd160.lock = function(): void { locked = true; } +ripemd160.register = function(func: (data: Uint8Array) => BytesLike) { + if (locked) { throw new TypeError("ripemd160 is locked"); } + __ripemd160 = func; +} +Object.freeze(ripemd160); diff --git a/src.ts/crypto/scrypt.ts b/src.ts/crypto/scrypt.ts new file mode 100644 index 000000000..83f5e0778 --- /dev/null +++ b/src.ts/crypto/scrypt.ts @@ -0,0 +1,48 @@ +import { scrypt as _nobleSync, scryptAsync as _nobleAsync } from "@noble/hashes/scrypt"; + +import { hexlify as H, logger } from "../utils/index.js"; + +import type { BytesLike } from "../utils/index.js"; + + +export type ProgressCallback = (percent: number) => void; + + +let lockedSync = false, lockedAsync = false; + +const _scryptAsync = async function(passwd: Uint8Array, salt: Uint8Array, N: number, r: number, p: number, dkLen: number, onProgress?: ProgressCallback) { + return await _nobleAsync(passwd, salt, { N, r, p, dkLen, onProgress }); +} +const _scryptSync = function(passwd: Uint8Array, salt: Uint8Array, N: number, r: number, p: number, dkLen: number) { + return _nobleSync(passwd, salt, { N, r, p, dkLen }); +} + +let __scryptAsync: (passwd: Uint8Array, salt: Uint8Array, N: number, r: number, p: number, dkLen: number, onProgress?: ProgressCallback) => Promise = _scryptAsync; +let __scryptSync: (passwd: Uint8Array, salt: Uint8Array, N: number, r: number, p: number, dkLen: number) => BytesLike = _scryptSync + + +export async function scrypt(_passwd: BytesLike, _salt: BytesLike, N: number, r: number, p: number, dkLen: number, progress?: ProgressCallback): Promise { + const passwd = logger.getBytes(_passwd, "passwd"); + const salt = logger.getBytes(_salt, "salt"); + return H(await __scryptAsync(passwd, salt, N, r, p, dkLen, progress)); +} +scrypt._ = _scryptAsync; +scrypt.lock = function(): void { lockedAsync = true; } +scrypt.register = function(func: (passwd: Uint8Array, salt: Uint8Array, N: number, r: number, p: number, dkLen: number, progress?: ProgressCallback) => Promise) { + if (lockedAsync) { throw new Error("scrypt is locked"); } + __scryptAsync = func; +} +Object.freeze(scrypt); + +export function scryptSync(_passwd: BytesLike, _salt: BytesLike, N: number, r: number, p: number, dkLen: number): string { + const passwd = logger.getBytes(_passwd, "passwd"); + const salt = logger.getBytes(_salt, "salt"); + return H(__scryptSync(passwd, salt, N, r, p, dkLen)); +} +scryptSync._ = _scryptSync; +scryptSync.lock = function(): void { lockedSync = true; } +scryptSync.register = function(func: (passwd: Uint8Array, salt: Uint8Array, N: number, r: number, p: number, dkLen: number) => BytesLike) { + if (lockedSync) { throw new Error("scryptSync is locked"); } + __scryptSync = func; +} +Object.freeze(scryptSync); diff --git a/src.ts/crypto/sha2.ts b/src.ts/crypto/sha2.ts new file mode 100644 index 000000000..6369fe3f5 --- /dev/null +++ b/src.ts/crypto/sha2.ts @@ -0,0 +1,44 @@ +import { createHash } from "./crypto.js"; + +import { hexlify, logger } from "../utils/index.js"; + +import type { BytesLike } from "../utils/index.js"; + + +const _sha256 = function(data: Uint8Array): Uint8Array { + return createHash("sha256").update(data).digest(); +} + +const _sha512 = function(data: Uint8Array): Uint8Array { + return createHash("sha512").update(data).digest(); +} + +let __sha256: (data: Uint8Array) => BytesLike = _sha256; +let __sha512: (data: Uint8Array) => BytesLike = _sha512; + +let locked256 = false, locked512 = false; + + +export function sha256(_data: BytesLike): string { + const data = logger.getBytes(_data, "data"); + return hexlify(__sha256(data)); +} +sha256._ = _sha256; +sha256.lock = function(): void { locked256 = true; } +sha256.register = function(func: (data: Uint8Array) => BytesLike): void { + if (locked256) { throw new Error("sha256 is locked"); } + __sha256 = func; +} +Object.freeze(sha256); + +export function sha512(_data: BytesLike): string { + const data = logger.getBytes(_data, "data"); + return hexlify(__sha512(data)); +} +sha512._ = _sha512; +sha512.lock = function(): void { locked512 = true; } +sha512.register = function(func: (data: Uint8Array) => BytesLike): void { + if (locked512) { throw new Error("sha512 is locked"); } + __sha512 = func; +} +Object.freeze(sha256); diff --git a/src.ts/crypto/signature.ts b/src.ts/crypto/signature.ts new file mode 100644 index 000000000..0de2c1a33 --- /dev/null +++ b/src.ts/crypto/signature.ts @@ -0,0 +1,261 @@ +import { ZeroHash } from "../constants/index.js"; +import { + concat, dataLength, getStore, hexlify, isHexString, logger, setStore +} from "../utils/index.js"; + +import type { BigNumberish, BytesLike, Freezable, Frozen } from "../utils/index.js"; + + +// Constants +const BN_0 = BigInt(0); +const BN_1 = BigInt(1); +const BN_2 = BigInt(2); +const BN_27 = BigInt(27); +const BN_28 = BigInt(28); +const BN_35 = BigInt(35); + + +const _guard = { }; + + +export type SignatureLike = Signature | string | { + r: string; + s: string; + v: BigNumberish; + yParity?: 0 | 1; + yParityAndS?: string; +} | { + r: string; + yParityAndS: string; + yParity?: 0 | 1; + s?: string; + v?: number; +} | { + r: string; + s: string; + yParity: 0 | 1; + v?: BigNumberish; + yParityAndS?: string; +}; + + +export class Signature implements Freezable { + #props: { r: string, s: string, v: 27 | 28, networkV: null | bigint }; + + get r(): string { return getStore(this.#props, "r"); } + set r(value: BytesLike) { + if (dataLength(value) !== 32) { + logger.throwArgumentError("invalid r", "value", value); + } + setStore(this.#props, "r", hexlify(value)); + } + + get s(): string { return getStore(this.#props, "s"); } + set s(value: BytesLike) { + if (dataLength(value) !== 32) { + logger.throwArgumentError("invalid r", "value", value); + } else if (logger.getBytes(value)[0] & 0x80) { + logger.throwArgumentError("non-canonical s", "value", value); + } + setStore(this.#props, "s", hexlify(value)); + } + + get v(): 27 | 28 { return getStore(this.#props, "v"); } + set v(value: BigNumberish) { + const v = logger.getNumber(value, "value"); + if (v !== 27 && v !== 28) { throw new Error("@TODO"); } + setStore(this.#props, "v", v); + } + + get networkV(): null | bigint { return getStore(this.#props, "networkV"); } + get legacyChainId(): null | bigint { + const v = this.networkV; + if (v == null) { return null; } + return Signature.getChainId(v); + } + + get yParity(): 0 | 1 { + if (this.v === 27) { return 0; } + return 1; + /* + // When v is 0 or 1 it is the recid directly + if (this.v.isZero()) { return 0; } + if (this.v.eq(1)) { return 1; } + + // Otherwise, odd (e.g. 27) is 0 and even (e.g. 28) is 1 + return this.v.and(1).isZero() ? 1: 0; + */ + } + + get yParityAndS(): string { + // The EIP-2098 compact representation + const yParityAndS = logger.getBytes(this.s); + if (this.yParity) { yParityAndS[0] |= 0x80; } + return hexlify(yParityAndS); + } + + get compactSerialized(): string { + return concat([ this.r, this.yParityAndS ]); + } + + get serialized(): string { + return concat([ this.r, this.s, (this.yParity ? "0x1c": "0x1b") ]); + } + + constructor(guard: any, r: string, s: string, v: 27 | 28) { + logger.assertPrivate(guard, _guard, "Signature"); + this.#props = { r, s, v, networkV: null }; + } + + [Symbol.for('nodejs.util.inspect.custom')]() { + return `Signature { r: "${ this.r }", s: "${ this.s }", yParity: ${ this.yParity }, networkV: ${ this.networkV } }`; + } + + clone(): Signature { + const clone = new Signature(_guard, this.r, this.s, this.v); + if (this.networkV) { setStore(clone.#props, "networkV", this.networkV); } + return clone; + } + + freeze(): Frozen { + Object.freeze(this.#props); + return this; + } + + isFrozen(): boolean { + return Object.isFrozen(this.#props); + } + + toJSON(): any { + const networkV = this.networkV; + return { + _type: "signature", + networkV: ((networkV != null) ? networkV.toString(): null), + r: this.r, s: this.s, v: this.v, + }; + } + + static create(): Signature { + return new Signature(_guard, ZeroHash, ZeroHash, 27); + } + + // Get the chain ID from an EIP-155 v + static getChainId(v: BigNumberish): bigint { + const bv = logger.getBigInt(v, "v"); + + // The v is not an EIP-155 v, so it is the unspecified chain ID + if ((bv == BN_27) || (bv == BN_28)) { return BN_0; } + + // Bad value for an EIP-155 v + if (bv < BN_35) { logger.throwArgumentError("invalid EIP-155 v", "v", v); } + + return (bv - BN_35) / BN_2; + } + + // Get the EIP-155 v transformed for a given chainId + static getChainIdV(chainId: BigNumberish, v: 27 | 28): bigint { + return (logger.getBigInt(chainId) * BN_2) + BigInt(35 + v - 27); + } + + // Convert an EIP-155 v into a normalized v + static getNormalizedV(v: BigNumberish): 27 | 28 { + const bv = logger.getBigInt(v); + + if (bv == BN_0) { return 27; } + if (bv == BN_1) { return 28; } + + // Otherwise, EIP-155 v means odd is 27 and even is 28 + return (bv & BN_1) ? 27: 28; + } + + static from(sig: SignatureLike): Signature { + const throwError = (message: string) => { + return logger.throwArgumentError(message, "signature", sig); + }; + + if (typeof(sig) === "string") { + const bytes = logger.getBytes(sig, "signature"); + if (bytes.length === 64) { + const r = hexlify(bytes.slice(0, 32)); + const s = bytes.slice(32, 64); + const v = (s[0] & 0x80) ? 28: 27; + s[0] &= 0x7f; + return new Signature(_guard, r, hexlify(s), v); + } + + if (dataLength(sig) !== 65) { + const r = hexlify(sig.slice(0, 32)); + const s = bytes.slice(32, 64); + if (s[0] & 0x80) { throwError("non-canonical s"); } + const v = Signature.getNormalizedV(bytes[64]); + return new Signature(_guard, r, hexlify(s), v); + } + + return throwError("invlaid raw signature length"); + } + + if (sig instanceof Signature) { return sig.clone(); } + + // Get r + const r = sig.r; + if (r == null) { throwError("missing r"); } + if (!isHexString(r, 32)) { throwError("invalid r"); } + + // Get s; by any means necessary (we check consistency below) + const s = (function(s?: string, yParityAndS?: string) { + if (s != null) { + if (!isHexString(s, 32)) { throwError("invalid s"); } + return s; + } + + if (yParityAndS != null) { + if (!isHexString(yParityAndS, 32)) { throwError("invalid yParityAndS"); } + const bytes = logger.getBytes(yParityAndS); + bytes[0] &= 0x7f; + return hexlify(bytes); + } + + return throwError("missing s"); + })(sig.s, sig.yParityAndS); + if (logger.getBytes(s)[0] & 0x80) { throwError("non-canonical s"); } + + // Get v; by any means necessary (we check consistency below) + const { networkV, v } = (function(_v?: BigNumberish, yParityAndS?: string, yParity?: number): { networkV?: bigint, v: 27 | 28 } { + if (_v != null) { + const v = logger.getBigInt(_v); + return { + networkV: ((v >= BN_35) ? v: undefined), + v: Signature.getNormalizedV(v) + }; + } + + if (yParityAndS != null) { + if (!isHexString(yParityAndS, 32)) { throwError("invalid yParityAndS"); } + return { v: ((logger.getBytes(yParityAndS)[0] & 0x80) ? 28: 27) }; + } + + if (yParity != null) { + switch (yParity) { + case 0: return { v: 27 }; + case 1: return { v: 28 }; + } + return throwError("invalid yParity"); + } + + return throwError("missing v"); + })(sig.v, sig.yParityAndS, sig.yParity); + + const result = new Signature(_guard, r, s, v); + if (networkV) { setStore(result.#props, "networkV", networkV); } + + // If multiple of v, yParity, yParityAndS we given, check they match + if ("yParity" in sig && sig.yParity !== result.yParity) { + throwError("yParity mismatch"); + } else if ("yParityAndS" in sig && sig.yParityAndS !== result.yParityAndS) { + throwError("yParityAndS mismatch"); + } + + return result; + } +} + diff --git a/src.ts/crypto/signing-key.ts b/src.ts/crypto/signing-key.ts new file mode 100644 index 000000000..4aeb24b77 --- /dev/null +++ b/src.ts/crypto/signing-key.ts @@ -0,0 +1,130 @@ + +import * as secp256k1 from "@noble/secp256k1"; + +import { computeHmac } from "../crypto/index.js"; +import { concat, hexlify, toHex, logger } from "../utils/index.js"; + +import { Signature } from "./signature.js"; + +import type { BytesLike, Frozen } from "../utils/index.js"; + +import type { SignatureLike } from "./index.js"; + + +//const N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + +// Make noble-secp256k1 sync +secp256k1.utils.hmacSha256Sync = function(key: Uint8Array, ...messages: Array): Uint8Array { + return logger.getBytes(computeHmac("sha256", key, concat(messages))); +} + +export class SigningKey { + #privateKey: string; + + constructor(privateKey: BytesLike) { + /* @TODO + logger.assertArgument(() => { + if (dataLength(privateKey) !== 32) { throw new Error("bad length"); } + return toBigInt(privateKey) < N; + }, "invalid private key", "privateKey", "[REDACTED]"); + */ + this.#privateKey = hexlify(privateKey); + } + + get privateKey(): string { return this.#privateKey; } + get publicKey(): string { return SigningKey.computePublicKey(this.#privateKey); } + get compressedPublicKey(): string { return SigningKey.computePublicKey(this.#privateKey, true); } + + sign(digest: BytesLike): Frozen { + /* @TODO + logger.assertArgument(() => (dataLength(digest) === 32), "invalid digest length", "digest", digest); + */ + + const [ sigDer, recid ] = secp256k1.signSync(logger.getBytesCopy(digest), logger.getBytesCopy(this.#privateKey), { + recovered: true, + canonical: true + }); + + const sig = secp256k1.Signature.fromHex(sigDer); + + return Signature.from({ + r: toHex("0x" + sig.r.toString(16), 32), + s: toHex("0x" + sig.s.toString(16), 32), + v: (recid ? 0x1c: 0x1b) + }).freeze(); + } + + computeShardSecret(other: BytesLike): string { + const pubKey = SigningKey.computePublicKey(other); + return hexlify(secp256k1.getSharedSecret(logger.getBytesCopy(this.#privateKey), pubKey)); + } + + static computePublicKey(key: BytesLike, compressed?: boolean): string { + let bytes = logger.getBytes(key, "key"); + + if (bytes.length === 32) { + const pubKey = secp256k1.getPublicKey(bytes, !!compressed); + return hexlify(pubKey); + } + + if (bytes.length === 64) { + const pub = new Uint8Array(65); + pub[0] = 0x04; + pub.set(bytes, 1); + bytes = pub; + } + + const point = secp256k1.Point.fromHex(bytes); + return hexlify(point.toRawBytes(compressed)); + } + + static recoverPublicKey(digest: BytesLike, signature: SignatureLike): string { + const sig = Signature.from(signature); + const der = secp256k1.Signature.fromCompact(logger.getBytesCopy(concat([ sig.r, sig.s ]))).toDERRawBytes(); + + const pubKey = secp256k1.recoverPublicKey(logger.getBytesCopy(digest), der, sig.yParity); + if (pubKey != null) { return hexlify(pubKey); } + + return logger.throwArgumentError("invalid signautre for digest", "signature", signature); + } + + static _addPoints(p0: BytesLike, p1: BytesLike, compressed?: boolean): string { + const pub0 = secp256k1.Point.fromHex(SigningKey.computePublicKey(p0).substring(2)); + const pub1 = secp256k1.Point.fromHex(SigningKey.computePublicKey(p1).substring(2)); + return "0x" + pub0.add(pub1).toHex(!!compressed) + } +} + +/* +const key = new SigningKey("0x1234567890123456789012345678901234567890123456789012345678901234"); +console.log(key); +console.log(key.sign("0x1234567890123456789012345678901234567890123456789012345678901234")); +{ + const privKey = "0x1234567812345678123456781234567812345678123456781234567812345678"; + const signingKey = new SigningKey(privKey); + console.log("0", signingKey, signingKey.publicKey, signingKey.publicKeyCompressed); + + let pubKey = SigningKey.computePublicKey(privKey); + let pubKeyComp = SigningKey.computePublicKey(privKey, true); + let pubKeyRaw = "0x" + SigningKey.computePublicKey(privKey).substring(4); + console.log("A", pubKey, pubKeyComp); + + let a = SigningKey.computePublicKey(pubKey); + let b = SigningKey.computePublicKey(pubKey, true); + console.log("B", a, b); + + a = SigningKey.computePublicKey(pubKeyComp); + b = SigningKey.computePublicKey(pubKeyComp, true); + console.log("C", a, b); + + a = SigningKey.computePublicKey(pubKeyRaw); + b = SigningKey.computePublicKey(pubKeyRaw, true); + console.log("D", a, b); + + const digest = "0x1122334411223344112233441122334411223344112233441122334411223344"; + const sig = signingKey.sign(digest); + console.log("SS", sig, sig.r, sig.s, sig.yParity); + + console.log("R", SigningKey.recoverPublicKey(digest, sig)); +} +*/ diff --git a/src.ts/ethers.ts b/src.ts/ethers.ts new file mode 100644 index 000000000..98982b11a --- /dev/null +++ b/src.ts/ethers.ts @@ -0,0 +1,147 @@ + + +///////////////////////////// +// + +export { version } from "./_version.js"; + +export { + formatBytes32String, parseBytes32String, + + AbiCoder, defaultAbiCoder, + ConstructorFragment, ErrorFragment, EventFragment, Fragment, FunctionFragment, ParamType, + + checkResultErrors, Indexed, Interface, LogDescription, Result, TransactionDescription, + Typed, +} from "./abi/index.js"; + +export { + getAddress, getIcapAddress, + getCreateAddress, getCreate2Address +} from "./address/index.js"; + +export { + ZeroAddress, + NegativeOne, Zero, One, Two, WeiPerEther, MaxUint256, MinInt256, MaxInt256, + ZeroHash, + EtherSymbol, MessagePrefix +} from "./constants/index.js"; + +export { + BaseContract, Contract, + ContractFactory, + ContractEventPayload, ContractTransactionReceipt, ContractTransactionResponse, EventLog, +} from "./contract/index.js"; + +export { + computeHmac, + randomBytes, + keccak256, + ripemd160, + sha256, sha512, + pbkdf2, + scrypt, scryptSync, + lock, + Signature, SigningKey +} from "./crypto/index.js"; + +export { + id, + //isValidName, namehash, dnsEncode + hashMessage, + solidityPacked, solidityPackedKeccak256, solidityPackedSha256, + TypedDataEncoder +} from "./hash/index.js"; + + +export { + accessListify, + computeAddress, recoverAddress, + Transaction +} from "./transaction/index.js"; + +export { + decodeBase58, encodeBase58, + isCallException, isError, + FetchRequest, FetchResponse, + FixedFormat, FixedNumber, formatFixed, parseFixed, + assertArgument, Logger, logger, + fromTwos, toTwos, mask, toArray, toBigInt, toHex, toNumber, + formatEther, parseEther, formatUnits, parseUnits, + _toEscapedUtf8String, toUtf8Bytes, toUtf8CodePoints, toUtf8String, + Utf8ErrorFuncs, + decodeRlp, encodeRlp +} from "./utils/index.js"; + +export { + defaultPath, + getAccountPath, + HDNodeWallet, HDNodeVoidWallet, HDNodeWalletManager, + isCrowdsaleJson, decryptCrowdsaleJson, + isKeystoreJson, decryptKeystoreJsonSync, decryptKeystoreJson, + encryptKeystoreJson, + Mnemonic, + Wallet +} from "./wallet/index.js"; + +export { + Wordlist, langEn, LangEn, wordlists, WordlistOwl, WordlistOwlA +} from "./wordlists/index.js"; + + + +///////////////////////////// +// Types + +export type { + JsonFragment, JsonFragmentType, + InterfaceAbi, +} from "./abi/index.js"; + +export type { Addressable } from "./address/index.js"; + +export type { + ConstantContractMethod, ContractEvent, ContractEventArgs, ContractEventName, + ContractInterface, ContractMethod, ContractMethodArgs, ContractTransaction, + DeferredTopicFilter, Overrides +} from "./contract/index.js"; + +export type { ProgressCallback, SignatureLike } from "./crypto/index.js"; + +export type { TypedDataDomain, TypedDataField } from "./hash/index.js"; + +export { + FallbackProvider, + JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner, + + AlchemyProvider, AnkrProvider, CloudflareProvider, EtherscanProvider, InfuraProvider, + //PocketProvider } from "./provider-pocket.js"; + + IpcSocketProvider, SocketProvider, WebSocketProvider, + + Network +} from "./providers/index.js"; + +export type { + AccessList, AccessListish, AccessListSet, + SignedTransaction, TransactionLike +} from "./transaction/index.js"; + +export type { + BytesLike, + BigNumberish, Numeric, + ErrorCode, + Utf8ErrorFunc, UnicodeNormalizationForm, Utf8ErrorReason, + RlpStructuredData, + + GetUrlResponse, + FetchRequestWithBody, FetchResponseWithBody, + FetchPreflightFunc, FetchProcessFunc, FetchRetryFunc, + FetchGatewayFunc, FetchGetUrlFunc +} from "./utils/index.js"; + +export type { + KeystoreAccountParams, KeystoreAccount, + EncryptOptions +} from "./wallet/index.js"; + diff --git a/src.ts/hash/id.ts b/src.ts/hash/id.ts new file mode 100644 index 000000000..8b0f08185 --- /dev/null +++ b/src.ts/hash/id.ts @@ -0,0 +1,6 @@ +import { keccak256 } from "../crypto/keccak.js"; +import { toUtf8Bytes } from "../utils/index.js"; + +export function id(value: string): string { + return keccak256(toUtf8Bytes(value)); +} diff --git a/src.ts/hash/index.ts b/src.ts/hash/index.ts new file mode 100644 index 000000000..6452a4940 --- /dev/null +++ b/src.ts/hash/index.ts @@ -0,0 +1,10 @@ + +export { id } from "./id.js" +export { isValidName, namehash, dnsEncode } from "./namehash.js"; +export { hashMessage } from "./message.js"; +export { + solidityPacked, solidityPackedKeccak256, solidityPackedSha256 +} from "./solidity.js"; +export { TypedDataEncoder } from "./typed-data.js"; + +export type { TypedDataDomain, TypedDataField } from "./typed-data.js"; diff --git a/src.ts/hash/message.ts b/src.ts/hash/message.ts new file mode 100644 index 000000000..f2c959738 --- /dev/null +++ b/src.ts/hash/message.ts @@ -0,0 +1,13 @@ +import { keccak256 } from "../crypto/keccak.js"; +import { MessagePrefix } from "../constants/index.js"; +import { concat, toUtf8Bytes } from "../utils/index.js"; + + +export function hashMessage(message: Uint8Array | string): string { + if (typeof(message) === "string") { message = toUtf8Bytes(message); } + return keccak256(concat([ + toUtf8Bytes(MessagePrefix), + toUtf8Bytes(String(message.length)), + message + ])); +} diff --git a/src.ts/hash/namehash.ts b/src.ts/hash/namehash.ts new file mode 100644 index 000000000..90dda9fe1 --- /dev/null +++ b/src.ts/hash/namehash.ts @@ -0,0 +1,84 @@ + +import { keccak256 } from "../crypto/keccak.js"; +import { concat, hexlify, logger, toUtf8Bytes, toUtf8String } from "../utils/index.js"; + + +//import { ens_normalize } from "./ens-normalize/lib"; +// @TOOD: +function ens_normalize(name: string): string { + return name; +} + +const Zeros = new Uint8Array(32); +Zeros.fill(0); + +function checkComponent(comp: Uint8Array): Uint8Array { + if (comp.length === 0) { throw new Error("invalid ENS name; empty component"); } + return comp; +} + +function ensNameSplit(name: string): Array { + const bytes = toUtf8Bytes(ens_normalize(name)); + const comps: Array = [ ]; + + if (name.length === 0) { return comps; } + + let last = 0; + for (let i = 0; i < bytes.length; i++) { + const d = bytes[i]; + + // A separator (i.e. "."); copy this component + if (d === 0x2e) { + comps.push(checkComponent(bytes.slice(last, i))); + last = i + 1; + } + } + + // There was a stray separator at the end of the name + if (last >= bytes.length) { throw new Error("invalid ENS name; empty component"); } + + comps.push(checkComponent(bytes.slice(last))); + return comps; +} + +export function ensNormalize(name: string): string { + return ensNameSplit(name).map((comp) => toUtf8String(comp)).join("."); +} + +export function isValidName(name: string): boolean { + try { + return (ensNameSplit(name).length !== 0); + } catch (error) { } + return false; +} + +export function namehash(name: string): string { + /* istanbul ignore if */ + if (typeof(name) !== "string") { + logger.throwArgumentError("invalid ENS name; not a string", "name", name); + } + + let result: string | Uint8Array = Zeros; + + const comps = ensNameSplit(name); + while (comps.length) { + result = keccak256(concat([ result, keccak256((comps.pop()))] )); + } + + return hexlify(result); +} + +export function dnsEncode(name: string): string { + return hexlify(concat(ensNameSplit(name).map((comp) => { + // DNS does not allow components over 63 bytes in length + if (comp.length > 63) { + throw new Error("invalid DNS encoded entry; length exceeds 63 bytes"); + } + + const bytes = new Uint8Array(comp.length + 1); + bytes.set(comp, 1); + bytes[0] = bytes.length - 1; + return bytes; + + }))) + "00"; +} diff --git a/src.ts/hash/solidity.ts b/src.ts/hash/solidity.ts new file mode 100644 index 000000000..ef5705d54 --- /dev/null +++ b/src.ts/hash/solidity.ts @@ -0,0 +1,115 @@ +import { + concat, dataLength, hexlify, logger, toArray, toTwos, toUtf8Bytes, zeroPadBytes, zeroPadValue +} from "../utils/index.js"; +import { keccak256 as _keccak256 } from "../crypto/keccak.js"; +import { sha256 as _sha256 } from "../crypto/sha2.js"; + + +const regexBytes = new RegExp("^bytes([0-9]+)$"); +const regexNumber = new RegExp("^(u?int)([0-9]*)$"); +const regexArray = new RegExp("^(.*)\\[([0-9]*)\\]$"); + + +function _pack(type: string, value: any, isArray?: boolean): Uint8Array { + switch(type) { + case "address": + if (isArray) { return logger.getBytes(zeroPadValue(value, 32)); } + return logger.getBytes(value); + case "string": + return toUtf8Bytes(value); + case "bytes": + return logger.getBytes(value); + case "bool": + value = (!!value ? "0x01": "0x00"); + if (isArray) { return logger.getBytes(zeroPadValue(value, 32)); } + return logger.getBytes(value); + } + + let match = type.match(regexNumber); + if (match) { + let size = parseInt(match[2] || "256") + + if ((match[2] && String(size) !== match[2]) || (size % 8 !== 0) || size === 0 || size > 256) { + return logger.throwArgumentError("invalid number type", "type", type) + } + + if (isArray) { size = 256; } + + value = toTwos(value, size); + + return logger.getBytes(zeroPadValue(toArray(value), size / 8)); + } + + match = type.match(regexBytes); + if (match) { + const size = parseInt(match[1]); + + if (String(size) !== match[1] || size === 0 || size > 32) { + return logger.throwArgumentError("invalid bytes type", "type", type) + } + if (dataLength(value) !== size) { + return logger.throwArgumentError(`invalid value for ${ type }`, "value", value) + } + if (isArray) { return logger.getBytes(zeroPadBytes(value, 32)); } + return value; + } + + match = type.match(regexArray); + if (match && Array.isArray(value)) { + const baseType = match[1]; + const count = parseInt(match[2] || String(value.length)); + if (count != value.length) { + logger.throwArgumentError(`invalid array length for ${ type }`, "value", value) + } + const result: Array = []; + value.forEach(function(value) { + result.push(_pack(baseType, value, true)); + }); + return logger.getBytes(concat(result)); + } + + return logger.throwArgumentError("invalid type", "type", type) +} + +// @TODO: Array Enum + +export function solidityPacked(types: ReadonlyArray, values: ReadonlyArray) { + if (types.length != values.length) { + logger.throwArgumentError("wrong number of values; expected ${ types.length }", "values", values) + } + const tight: Array = []; + types.forEach(function(type, index) { + tight.push(_pack(type, values[index])); + }); + return hexlify(concat(tight)); +} + +/** + * Computes the non-standard packed (tightly packed) keccak256 hash of + * the values given the types. + * + * @param {Array} types - The Solidity types to interpret each value as [default: bar] + * @param {Array} values - The values to pack + * + * @returns: {HexString} the hexstring of the hash + * @example: + * solidityPackedKeccak256([ "address", "uint" ], [ "0x1234", 45 ]); + * //_result: + * + * @see https://docs.soliditylang.org/en/v0.8.14/abi-spec.html#non-standard-packed-mode + */ +export function solidityPackedKeccak256(types: ReadonlyArray, values: ReadonlyArray) { + return _keccak256(solidityPacked(types, values)); +} + +/** + * Test Function, for fun + * + * @param foo - something fun + */ +export function test(foo: number | string): void { +} + +export function solidityPackedSha256(types: ReadonlyArray, values: ReadonlyArray) { + return _sha256(solidityPacked(types, values)); +} diff --git a/src.ts/hash/typed-data.ts b/src.ts/hash/typed-data.ts new file mode 100644 index 000000000..1252800bd --- /dev/null +++ b/src.ts/hash/typed-data.ts @@ -0,0 +1,520 @@ +//import { TypedDataDomain, TypedDataField } from "@ethersproject/providerabstract-signer"; +import { getAddress } from "../address/index.js"; +import { keccak256 } from "../crypto/index.js"; +import { + concat, defineProperties, hexlify, isHexString, mask, toHex, toTwos, zeroPadValue +} from "../utils/index.js"; + +import { id } from "./id.js"; +import { logger } from "../utils/logger.js"; + +import type { BigNumberish, BytesLike } from "../utils/index.js"; + + +const padding = new Uint8Array(32); +padding.fill(0); + +const BN__1 = BigInt(-1); +const BN_0 = BigInt(0); +const BN_1 = BigInt(1); +const BN_MAX_UINT256 = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + +export interface TypedDataDomain { + name?: string; + version?: string; + chainId?: BigNumberish; + verifyingContract?: string; + salt?: BytesLike; +}; + +export interface TypedDataField { + name: string; + type: string; +}; + +function hexPadRight(value: BytesLike) { + const bytes = logger.getBytes(value); + const padOffset = bytes.length % 32 + if (padOffset) { + return concat([ bytes, padding.slice(padOffset) ]); + } + return hexlify(bytes); +} + +const hexTrue = toHex(BN_1, 32); +const hexFalse = toHex(BN_0, 32); + +const domainFieldTypes: Record = { + name: "string", + version: "string", + chainId: "uint256", + verifyingContract: "address", + salt: "bytes32" +}; + +const domainFieldNames: Array = [ + "name", "version", "chainId", "verifyingContract", "salt" +]; + +function checkString(key: string): (value: any) => string { + return function (value: any){ + if (typeof(value) !== "string") { + logger.throwArgumentError(`invalid domain value for ${ JSON.stringify(key) }`, `domain.${ key }`, value); + } + return value; + } +} + +const domainChecks: Record any> = { + name: checkString("name"), + version: checkString("version"), + chainId: function(value: any) { + return logger.getBigInt(value, "domain.chainId"); + }, + verifyingContract: function(value: any) { + try { + return getAddress(value).toLowerCase(); + } catch (error) { } + return logger.throwArgumentError(`invalid domain value "verifyingContract"`, "domain.verifyingContract", value); + }, + salt: function(value: any) { + const bytes = logger.getBytes(value, "domain.salt"); + if (bytes.length !== 32) { + logger.throwArgumentError(`invalid domain value "salt"`, "domain.salt", value); + } + return hexlify(bytes); + } +} + +function getBaseEncoder(type: string): null | ((value: any) => string) { + // intXX and uintXX + { + const match = type.match(/^(u?)int(\d*)$/); + if (match) { + const signed = (match[1] === ""); + + const width = parseInt(match[2] || "256"); + if (width % 8 !== 0 || width > 256 || (match[2] && match[2] !== String(width))) { + logger.throwArgumentError("invalid numeric width", "type", type); + } + + const boundsUpper = mask(BN_MAX_UINT256, signed ? (width - 1): width); + const boundsLower = signed ? ((boundsUpper + BN_1) * BN__1): BN_0; + + return function(_value: BigNumberish) { + const value = logger.getBigInt(_value, "value"); + + if (value < boundsLower || value > boundsUpper) { + logger.throwArgumentError(`value out-of-bounds for ${ type }`, "value", value); + } + + return toHex(toTwos(value, 256), 32); + }; + } + } + + // bytesXX + { + const match = type.match(/^bytes(\d+)$/); + if (match) { + const width = parseInt(match[1]); + if (width === 0 || width > 32 || match[1] !== String(width)) { + logger.throwArgumentError("invalid bytes width", "type", type); + } + + return function(value: BytesLike) { + const bytes = logger.getBytes(value); + if (bytes.length !== width) { + logger.throwArgumentError(`invalid length for ${ type }`, "value", value); + } + return hexPadRight(value); + }; + } + } + + switch (type) { + case "address": return function(value: string) { + return zeroPadValue(getAddress(value), 32); + }; + case "bool": return function(value: boolean) { + return ((!value) ? hexFalse: hexTrue); + }; + case "bytes": return function(value: BytesLike) { + return keccak256(value); + }; + case "string": return function(value: string) { + return id(value); + }; + } + + return null; +} + +function encodeType(name: string, fields: Array): string { + return `${ name }(${ fields.map(({ name, type }) => (type + " " + name)).join(",") })`; +} + +export class TypedDataEncoder { + readonly primaryType!: string; + + readonly #types: string; + get types(): Record> { + return JSON.parse(this.#types); + } + + readonly #fullTypes: Map + + readonly #encoderCache: Map string>; + + constructor(types: Record>) { + this.#types = JSON.stringify(types); + this.#fullTypes = new Map(); + this.#encoderCache = new Map(); + + // Link struct types to their direct child structs + const links: Map> = new Map(); + + // Link structs to structs which contain them as a child + const parents: Map> = new Map(); + + // Link all subtypes within a given struct + const subtypes: Map> = new Map(); + + Object.keys(types).forEach((type) => { + links.set(type, new Set()); + parents.set(type, [ ]); + subtypes.set(type, new Set()); + }); + + for (const name in types) { + const uniqueNames: Set = new Set(); + + for (const field of types[name]) { + + // Check each field has a unique name + if (uniqueNames.has(field.name)) { + logger.throwArgumentError(`duplicate variable name ${ JSON.stringify(field.name) } in ${ JSON.stringify(name) }`, "types", types); + } + uniqueNames.add(field.name); + + // Get the base type (drop any array specifiers) + const baseType = ((field.type.match(/^([^\x5b]*)(\x5b|$)/)))[1] || null; + if (baseType === name) { + logger.throwArgumentError(`circular type reference to ${ JSON.stringify(baseType) }`, "types", types); + } + + // Is this a base encoding type? + const encoder = getBaseEncoder(baseType); + if (encoder) { continue; } + + if (!parents.has(baseType)) { + logger.throwArgumentError(`unknown type ${ JSON.stringify(baseType) }`, "types", types); + } + + // Add linkage + (parents.get(baseType) as Array).push(name); + (links.get(name) as Set).add(baseType); + } + } + + // Deduce the primary type + const primaryTypes = Array.from(parents.keys()).filter((n) => ((parents.get(n) as Array).length === 0)); + + if (primaryTypes.length === 0) { + logger.throwArgumentError("missing primary type", "types", types); + } else if (primaryTypes.length > 1) { + logger.throwArgumentError(`ambiguous primary types or unused types: ${ primaryTypes.map((t) => (JSON.stringify(t))).join(", ") }`, "types", types); + } + + defineProperties(this, { primaryType: primaryTypes[0] }); + + // Check for circular type references + function checkCircular(type: string, found: Set) { + if (found.has(type)) { + logger.throwArgumentError(`circular type reference to ${ JSON.stringify(type) }`, "types", types); + } + + found.add(type); + + for (const child of (links.get(type) as Set)) { + if (!parents.has(child)) { continue; } + + // Recursively check children + checkCircular(child, found); + + // Mark all ancestors as having this decendant + for (const subtype of found) { + (subtypes.get(subtype) as Set).add(child); + } + } + + found.delete(type); + } + checkCircular(this.primaryType, new Set()); + + // Compute each fully describe type + for (const [ name, set ] of subtypes) { + const st = Array.from(set); + st.sort(); + this.#fullTypes.set(name, encodeType(name, types[name]) + st.map((t) => encodeType(t, types[t])).join("")); + } + } + + getEncoder(type: string): (value: any) => string { + let encoder = this.#encoderCache.get(type); + if (!encoder) { + encoder = this.#getEncoder(type); + this.#encoderCache.set(type, encoder); + } + return encoder; + } + + #getEncoder(type: string): (value: any) => string { + + // Basic encoder type (address, bool, uint256, etc) + { + const encoder = getBaseEncoder(type); + if (encoder) { return encoder; } + } + + // Array + const match = type.match(/^(.*)(\x5b(\d*)\x5d)$/); + if (match) { + const subtype = match[1]; + const subEncoder = this.getEncoder(subtype); + const length = parseInt(match[3]); + return (value: Array) => { + if (length >= 0 && value.length !== length) { + logger.throwArgumentError("array length mismatch; expected length ${ arrayLength }", "value", value); + } + + let result = value.map(subEncoder); + if (this.#fullTypes.has(subtype)) { + result = result.map(keccak256); + } + + return keccak256(concat(result)); + }; + } + + // Struct + const fields = this.types[type]; + if (fields) { + const encodedType = id(this.#fullTypes.get(type) as string); + return (value: Record) => { + const values = fields.map(({ name, type }) => { + const result = this.getEncoder(type)(value[name]); + if (this.#fullTypes.has(type)) { return keccak256(result); } + return result; + }); + values.unshift(encodedType); + return concat(values); + } + } + + return logger.throwArgumentError(`unknown type: ${ type }`, "type", type); + } + + encodeType(name: string): string { + const result = this.#fullTypes.get(name); + if (!result) { + return logger.throwArgumentError(`unknown type: ${ JSON.stringify(name) }`, "name", name); + } + return result; + } + + encodeData(type: string, value: any): string { + return this.getEncoder(type)(value); + } + + hashStruct(name: string, value: Record): string { + return keccak256(this.encodeData(name, value)); + } + + encode(value: Record): string { + return this.encodeData(this.primaryType, value); + } + + hash(value: Record): string { + return this.hashStruct(this.primaryType, value); + } + + _visit(type: string, value: any, callback: (type: string, data: any) => any): any { + // Basic encoder type (address, bool, uint256, etc) + { + const encoder = getBaseEncoder(type); + if (encoder) { return callback(type, value); } + } + + // Array + const match = type.match(/^(.*)(\x5b(\d*)\x5d)$/); + if (match) { + const subtype = match[1]; + const length = parseInt(match[3]); + if (length >= 0 && value.length !== length) { + logger.throwArgumentError("array length mismatch; expected length ${ arrayLength }", "value", value); + } + return value.map((v: any) => this._visit(subtype, v, callback)); + } + + // Struct + const fields = this.types[type]; + if (fields) { + return fields.reduce((accum, { name, type }) => { + accum[name] = this._visit(type, value[name], callback); + return accum; + }, >{}); + } + + return logger.throwArgumentError(`unknown type: ${ type }`, "type", type); + } + + visit(value: Record, callback: (type: string, data: any) => any): any { + return this._visit(this.primaryType, value, callback); + } + + static from(types: Record>): TypedDataEncoder { + return new TypedDataEncoder(types); + } + + static getPrimaryType(types: Record>): string { + return TypedDataEncoder.from(types).primaryType; + } + + static hashStruct(name: string, types: Record>, value: Record): string { + return TypedDataEncoder.from(types).hashStruct(name, value); + } + + static hashDomain(domain: TypedDataDomain): string { + const domainFields: Array = [ ]; + for (const name in domain) { + const type = domainFieldTypes[name]; + if (!type) { + logger.throwArgumentError(`invalid typed-data domain key: ${ JSON.stringify(name) }`, "domain", domain); + } + domainFields.push({ name, type }); + } + + domainFields.sort((a, b) => { + return domainFieldNames.indexOf(a.name) - domainFieldNames.indexOf(b.name); + }); + + return TypedDataEncoder.hashStruct("EIP712Domain", { EIP712Domain: domainFields }, domain); + } + + static encode(domain: TypedDataDomain, types: Record>, value: Record): string { + return concat([ + "0x1901", + TypedDataEncoder.hashDomain(domain), + TypedDataEncoder.from(types).hash(value) + ]); + } + + static hash(domain: TypedDataDomain, types: Record>, value: Record): string { + return keccak256(TypedDataEncoder.encode(domain, types, value)); + } + + // Replaces all address types with ENS names with their looked up address + static async resolveNames(domain: TypedDataDomain, types: Record>, value: Record, resolveName: (name: string) => Promise): Promise<{ domain: TypedDataDomain, value: any }> { + // Make a copy to isolate it from the object passed in + domain = Object.assign({ }, domain); + + // Look up all ENS names + const ensCache: Record = { }; + + // Do we need to look up the domain's verifyingContract? + if (domain.verifyingContract && !isHexString(domain.verifyingContract, 20)) { + ensCache[domain.verifyingContract] = "0x"; + } + + // We are going to use the encoder to visit all the base values + const encoder = TypedDataEncoder.from(types); + + // Get a list of all the addresses + encoder.visit(value, (type: string, value: any) => { + if (type === "address" && !isHexString(value, 20)) { + ensCache[value] = "0x"; + } + return value; + }); + + // Lookup each name + for (const name in ensCache) { + ensCache[name] = await resolveName(name); + } + + // Replace the domain verifyingContract if needed + if (domain.verifyingContract && ensCache[domain.verifyingContract]) { + domain.verifyingContract = ensCache[domain.verifyingContract]; + } + + // Replace all ENS names with their address + value = encoder.visit(value, (type: string, value: any) => { + if (type === "address" && ensCache[value]) { return ensCache[value]; } + return value; + }); + + return { domain, value }; + } + + static getPayload(domain: TypedDataDomain, types: Record>, value: Record): any { + // Validate the domain fields + TypedDataEncoder.hashDomain(domain); + + // Derive the EIP712Domain Struct reference type + const domainValues: Record = { }; + const domainTypes: Array<{ name: string, type:string }> = [ ]; + + domainFieldNames.forEach((name) => { + const value = (domain)[name]; + if (value == null) { return; } + domainValues[name] = domainChecks[name](value); + domainTypes.push({ name, type: domainFieldTypes[name] }); + }); + + const encoder = TypedDataEncoder.from(types); + + const typesWithDomain = Object.assign({ }, types); + if (typesWithDomain.EIP712Domain) { + logger.throwArgumentError("types must not contain EIP712Domain type", "types.EIP712Domain", types); + } else { + typesWithDomain.EIP712Domain = domainTypes; + } + + // Validate the data structures and types + encoder.encode(value); + + return { + types: typesWithDomain, + domain: domainValues, + primaryType: encoder.primaryType, + message: encoder.visit(value, (type: string, value: any) => { + + // bytes + if (type.match(/^bytes(\d*)/)) { + return hexlify(logger.getBytes(value)); + } + + // uint or int + if (type.match(/^u?int/)) { + return logger.getBigInt(value).toString(); + } + + switch (type) { + case "address": + return value.toLowerCase(); + case "bool": + return !!value; + case "string": + if (typeof(value) !== "string") { + logger.throwArgumentError(`invalid string`, "value", value); + } + return value; + } + + return logger.throwArgumentError("unsupported type", "type", type); + }) + }; + } +} + diff --git a/src.ts/index.ts b/src.ts/index.ts new file mode 100644 index 000000000..7e55b4067 --- /dev/null +++ b/src.ts/index.ts @@ -0,0 +1,6 @@ + +import * as ethers from "./ethers.js"; + +export { ethers }; + +export * from "./ethers.js"; diff --git a/src.ts/providers/abstract-provider.ts b/src.ts/providers/abstract-provider.ts new file mode 100644 index 000000000..707d00e81 --- /dev/null +++ b/src.ts/providers/abstract-provider.ts @@ -0,0 +1,1314 @@ +// @TODO +// Event coalescence +// When we register an event with an async value (e.g. address is a Signer +// or ENS name), we need to add it immeidately for the Event API, but also +// need time to resolve the address. Upon resolving the address, we need to +// migrate the listener to the static event. We also need to maintain a map +// of Signer/ENS name to address so we can sync respond to listenerCount. + +import { resolveAddress } from "../address/index.js"; +import { concat, dataLength, dataSlice, hexlify, isHexString } from "../utils/data.js"; +import { isCallException } from "../utils/errors.js"; +import { FetchRequest } from "../utils/fetch.js"; +import { toArray, toQuantity } from "../utils/maths.js"; +import { defineProperties, EventPayload, resolveProperties } from "../utils/index.js"; +import { toUtf8String } from "../utils/index.js";; + +import { logger } from "../utils/logger.js"; + +import { EnsResolver } from "./ens-resolver.js"; +import { Network } from "./network.js"; +import { Block, FeeData, Log, TransactionReceipt, TransactionResponse } from "./provider.js"; +import { + PollingBlockSubscriber, PollingEventSubscriber, PollingOrphanSubscriber, PollingTransactionSubscriber +} from "./subscriber-polling.js"; + +import type { Addressable, AddressLike } from "../address/index.js"; +import type { BigNumberish, BytesLike } from "../utils/index.js"; +import type { Frozen, Listener } from "../utils/index.js"; +import type { AccessList } from "../transaction/index.js"; + +import type { Networkish } from "./network.js"; +//import type { MaxPriorityFeePlugin } from "./plugins-network.js"; +import type { + BlockTag, CallRequest, EventFilter, Filter, FilterByBlockHash, + LogParams, OrphanFilter, Provider, ProviderEvent, TransactionRequest, +} from "./provider.js"; + + +// Constants +const BN_2 = BigInt(2); + +const MAX_CCIP_REDIRECTS = 10; + + +function getTag(prefix: string, value: any): string { + return prefix + ":" + JSON.stringify(value, (k, v) => { + if (typeof(v) === "bigint") { return `bigint:${ v.toString() }`} + if (typeof(v) === "string") { return v.toLowerCase(); } + + // Sort object keys + if (typeof(v) === "object" && !Array.isArray(v)) { + const keys = Object.keys(v); + keys.sort(); + return keys.reduce((accum, key) => { + accum[key] = v[key]; + return accum; + }, { }); + } + + return v; + }); +} + +// Only sub-classes overriding the _getSubscription method will care about this +export type Subscription = { + type: "block" | "close" | "debug" | "network" | "pending", + tag: string +} | { + type: "transaction", + tag: string, + hash: string +} | { + type: "event", + tag: string, + filter: EventFilter +} | { + type: "orphan", + tag: string, + filter: OrphanFilter +}; + +export interface Subscriber { + start(): void; + stop(): void; + + pause(dropWhilePaused?: boolean): void; + resume(): void; + + // Subscribers which use polling should implement this to allow + // Providers the ability to update underlying polling intervals + // If not supported, accessing this property should return undefined + pollingInterval?: number; +} + +export class UnmanagedSubscriber implements Subscriber { + name!: string; + + constructor(name: string) { defineProperties(this, { name }); } + + start(): void { } + stop(): void { } + + pause(dropWhilePaused?: boolean): void { } + resume(): void { } +} + +type Sub = { + tag: string; + nameMap: Map + addressableMap: WeakMap; + listeners: Array<{ listener: Listener, once: boolean }>; + started: boolean; + subscriber: Subscriber; +}; + +function copy(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +function concisify(items: Array): Array { + items = Array.from((new Set(items)).values()) + items.sort(); + return items; +} + +// Normalize a ProviderEvent into a Subscription +// @TODO: Make events sync if possible; like block +//function getSyncSubscription(_event: ProviderEvent): Subscription { +//} + +async function getSubscription(_event: ProviderEvent, provider: AbstractProvider): Promise { + if (_event == null) { throw new Error("invalid event"); } + + // Normalize topic array info an EventFilter + if (Array.isArray(_event)) { _event = { topics: _event }; } + + if (typeof(_event) === "string") { + switch (_event) { + case "block": case "pending": case "debug": case "network": { + return { type: _event, tag: _event }; + } + } + } + + if (isHexString(_event, 32)) { + const hash = _event.toLowerCase(); + return { type: "transaction", tag: getTag("tx", { hash }), hash }; + } + + if ((_event).orphan) { + const event = _event; + // @TODO: Should lowercase and whatnot things here instead of copy... + return { type: "orphan", tag: getTag("orphan", event), filter: copy(event) }; + } + + if (((_event).address || (_event).topics)) { + const event = _event; + + const filter: any = { + topics: ((event.topics || []).map((t) => { + if (t == null) { return null; } + if (Array.isArray(t)) { + return concisify(t.map((t) => t.toLowerCase())); + } + return t.toLowerCase(); + })) + }; + + if (event.address) { + const addresses: Array = [ ]; + const promises: Array> = [ ]; + + const addAddress = (addr: AddressLike) => { + if (isHexString(addr)) { + addresses.push(addr); + } else { + promises.push((async () => { + addresses.push(await resolveAddress(addr, provider)); + })()); + } + } + + + if (Array.isArray(event.address)) { + event.address.forEach(addAddress); + } else { + addAddress(event.address); + } + if (promises.length) { await Promise.all(promises); } + filter.address = concisify(addresses.map((a) => a.toLowerCase())); + } + + return { filter, tag: getTag("event", filter), type: "event" }; + } + + return logger.throwArgumentError("unknown ProviderEvent", "event", _event); +} + +function getTime(): number { return (new Date()).getTime(); } + +export interface ProviderPlugin { + readonly name: string; + validate(provider: Provider): ProviderPlugin; +} + +export type PerformActionFilter = { + address?: string | Array; + topics?: Array>; + fromBlock?: BlockTag; + toBlock?: BlockTag; +} | { + address?: string | Array; + topics?: Array>; + blockHash?: string; +}; + +export type PerformActionTransaction = { + type?: number; + + to?: string; + from?: string; + + nonce?: number; + + gasLimit?: bigint; + gasPrice?: bigint; + + maxPriorityFeePerGas?: bigint; + maxFeePerGas?: bigint; + + data?: string; + value?: bigint; + chainId?: bigint; + + accessList?: AccessList; +}; + +export type PerformActionRequest = { + method: "call", + transaction: PerformActionTransaction, blockTag: BlockTag +} | { + method: "chainId" +} | { + method: "estimateGas", + transaction: PerformActionTransaction +} | { + method: "getBalance", + address: string, blockTag: BlockTag +} | { + method: "getBlock", + blockTag: BlockTag, includeTransactions: boolean +} | { + method: "getBlock", + blockHash: string, includeTransactions: boolean +} | { + method: "getBlockNumber" +} | { + method: "getCode", + address: string, blockTag: BlockTag +} | { + method: "getGasPrice" +} | { + method: "getLogs", + filter: PerformActionFilter +} | { + method: "getStorageAt", + address: string, position: bigint, blockTag: BlockTag +} | { + method: "getTransaction", + hash: string +} | { + method: "getTransactionCount", + address: string, blockTag: BlockTag +} | { + method: "getTransactionReceipt", + hash: string +} | { + method: "getTransactionResult", + hash: string +} | { + method: "broadcastTransaction", // @TODO: rename to broadcast + signedTransaction: string +}; + +type _PerformAccountRequest = { + method: "getBalance" | "getTransactionCount" | "getCode" +} | { + method: "getStorageAt", position: bigint +} + +export function copyRequest(tx: T): T { + // @TODO: copy the copy from contracts and use it from this + return tx; +} + +type CcipArgs = { + sender: string; + urls: Array; + calldata: string; + selector: string; + extraData: string; + errorArgs: Array +}; + + + +export class AbstractProvider implements Provider { + + #subs: Map; + #plugins: Map; + + // null=unpaused, true=paused+dropWhilePaused, false=paused + #pausedState: null | boolean; + + #networkPromise: null | Promise>; + readonly #anyNetwork: boolean; + + #performCache: Map>; + + #nextTimer: number; + #timers: Map void, time: number }>; + + #disableCcipRead: boolean; + + // @TODO: This should be a () => Promise so network can be + // done when needed; or rely entirely on _detectNetwork? + constructor(_network?: "any" | Networkish) { + if (_network === "any") { + this.#anyNetwork = true; + this.#networkPromise = null; + } else if (_network) { + const network = Network.from(_network); + this.#anyNetwork = false; + this.#networkPromise = Promise.resolve(network); + setTimeout(() => { this.emit("network", network, null); }, 0); + } else { + this.#anyNetwork = false; + this.#networkPromise = null; + } + + this.#performCache = new Map(); + + this.#subs = new Map(); + this.#plugins = new Map(); + this.#pausedState = null; + + this.#nextTimer = 0; + this.#timers = new Map(); + + this.#disableCcipRead = false; + } + + get provider(): this { return this; } + + get plugins(): Array { + return Array.from(this.#plugins.values()); + } + + attachPlugin(plugin: ProviderPlugin): this { + if (this.#plugins.get(plugin.name)) { + throw new Error(`cannot replace existing plugin: ${ plugin.name } `); + } + this.#plugins.set(plugin.name, plugin.validate(this)); + return this; + } + + getPlugin(name: string): null | T { + return (this.#plugins.get(name)) || null; + } + + set disableCcipRead(value: boolean) { this.#disableCcipRead = !!value; } + get disableCcipRead(): boolean { return this.#disableCcipRead; } + + // Shares multiple identical requests made during the same 250ms + async #perform(req: PerformActionRequest): Promise { + // Create a tag + const tag = getTag(req.method, req); + + let perform = this.#performCache.get(tag); + if (!perform) { + perform = this._perform(req); + this.#performCache.set(tag, perform); + + setTimeout(() => { + if (this.#performCache.get(tag) === perform) { + this.#performCache.delete(tag); + } + }, 250); + } + + return await perform; + } + + async ccipReadFetch(tx: PerformActionTransaction, calldata: string, urls: Array): Promise { + if (this.disableCcipRead || urls.length === 0 || tx.to == null) { return null; } + + const sender = tx.to.toLowerCase(); + const data = calldata.toLowerCase(); + + const errorMessages: Array = [ ]; + + for (let i = 0; i < urls.length; i++) { + const url = urls[i]; + + // URL expansion + const href = url.replace("{sender}", sender).replace("{data}", data); + + // If no {data} is present, use POST; otherwise GET + //const json: string | null = (url.indexOf("{data}") >= 0) ? null: JSON.stringify({ data, sender }); + + //const result = await fetchJson({ url: href, errorPassThrough: true }, json, (value, response) => { + // value.status = response.statusCode; + // return value; + //}); + const request = new FetchRequest(href); + if (url.indexOf("{data}") === -1) { + request.body = { data, sender }; + } + + let errorMessage = "unknown error"; + + const resp = await request.send(); + try { + const result = resp.bodyJson; + if (result.data) { return result.data; } + if (result.message) { errorMessage = result.message; } + } catch (error) { } + + // 4xx indicates the result is not present; stop + if (resp.statusCode >= 400 && resp.statusCode < 500) { + return logger.throwError(`response not found during CCIP fetch: ${ errorMessage }`, "OFFCHAIN_FAULT", { + reason: "404_MISSING_RESOURCE", + transaction: tx, info: { url, errorMessage } + }); + } + + // 5xx indicates server issue; try the next url + errorMessages.push(errorMessage); + } + + return logger.throwError(`error encountered during CCIP fetch: ${ errorMessages.map((m) => JSON.stringify(m)).join(", ") }`, "OFFCHAIN_FAULT", { + reason: "500_SERVER_ERROR", + transaction: tx, info: { urls, errorMessages } + }); + } + + _wrapTransaction(tx: TransactionResponse, hash: string, blockNumber: number): TransactionResponse { + return tx; + } + + _detectNetwork(): Promise> { + return logger.throwError("sub-classes must implement this", "UNSUPPORTED_OPERATION", { + operation: "_detectNetwork" + }); + } + + // Sub-classes should override this and handle PerformActionRequest requests, calling + // the super for any unhandled actions. + async _perform(req: PerformActionRequest): Promise { + return logger.throwError(`unsupported method: ${ req.method }`, "UNSUPPORTED_OPERATION", { + operation: req.method, + info: req + }); + } + + // State + async getBlockNumber(): Promise { + return logger.getNumber(await this.#perform({ method: "getBlockNumber" }), "%response"); + } + +// @TODO: Make this string | Promsie so no await needed if sync is possible + _getAddress(address: AddressLike): string | Promise { + return resolveAddress(address, this); + /* + if (typeof(address) === "string") { + if (address.match(/^0x[0-9a-f]+$/i)) { return address; } + const resolved = await this.resolveName(address); + if (resolved == null) { throw new Error("not confiugred @TODO"); } + return resolved; + } + return address.getAddress(); + */ + } + + _getBlockTag(blockTag?: BlockTag): string | Promise { + if (blockTag == null) { return "latest"; } + + switch (blockTag) { + case "earliest": + return "0x0"; + case "latest": case "pending": case "safe": case "finalized": + return blockTag; + } + + if (isHexString(blockTag)) { + if (dataLength(blockTag) === 32) { return blockTag; } + return toQuantity(blockTag); + } + + if (typeof(blockTag) === "number") { + if (blockTag >= 0) { return toQuantity(blockTag); } + return this.getBlockNumber().then((b) => toQuantity(b + blockTag)); + } + + return logger.throwArgumentError("invalid blockTag", "blockTag", blockTag); + } + + async getNetwork(): Promise> { + + // No explicit network was set and this is our first time + if (this.#networkPromise == null) { + + // Detect the current network (shared with all calls) + const detectNetwork = this._detectNetwork().then((network) => { + this.emit("network", network, null); + return network; + }, (error) => { + // Reset the networkPromise on failure, so we will try again + if (this.#networkPromise === detectNetwork) { + this.#networkPromise = null; + } + throw error; + }); + + this.#networkPromise = detectNetwork; + return await detectNetwork; + } + + const networkPromise = this.#networkPromise; + + const [ expected, actual ] = await Promise.all([ + networkPromise, // Possibly an explicit Network + this._detectNetwork() // The actual connected network + ]); + + if (expected.chainId !== actual.chainId) { + if (this.#anyNetwork) { + // The "any" network can change, so notify listeners + this.emit("network", actual, expected); + + // Update the network if something else hasn't already changed it + if (this.#networkPromise === networkPromise) { + this.#networkPromise = Promise.resolve(actual); + } + } else { + // Otherwise, we do not allow changes to the underlying network + logger.throwError(`network changed: ${ expected.chainId } => ${ actual.chainId } `, "NETWORK_ERROR", { + event: "changed" + }); + } + } + + return expected.clone().freeze(); + } + + async getFeeData(): Promise { + const { block, gasPrice } = await resolveProperties({ + block: this.getBlock("latest"), + gasPrice: ((async () => { + try { + const gasPrice = await this.#perform({ method: "getGasPrice" }); + return logger.getBigInt(gasPrice, "%response"); + } catch (error) { } + return null + })()) + }); + + let maxFeePerGas = null, maxPriorityFeePerGas = null; + + if (block && block.baseFeePerGas) { + // We may want to compute this more accurately in the future, + // using the formula "check if the base fee is correct". + // See: https://eips.ethereum.org/EIPS/eip-1559 + maxPriorityFeePerGas = BigInt("1500000000"); + + // Allow a network to override their maximum priority fee per gas + //const priorityFeePlugin = (await this.getNetwork()).getPlugin("org.ethers.plugins.max-priority-fee"); + //if (priorityFeePlugin) { + // maxPriorityFeePerGas = await priorityFeePlugin.getPriorityFee(this); + //} + maxFeePerGas = (block.baseFeePerGas * BN_2) + maxPriorityFeePerGas; + } + + return new FeeData(gasPrice, maxFeePerGas, maxPriorityFeePerGas); + } + + async _getTransaction(_request: CallRequest): Promise { + const network = await this.getNetwork(); + + // Fill in any addresses + const request = Object.assign({}, _request, await resolveProperties({ + to: (_request.to ? resolveAddress(_request.to, this): undefined), + from: (_request.from ? resolveAddress(_request.from, this): undefined), + })); + + return network.formatter.transactionRequest(request); + } + + async estimateGas(_tx: TransactionRequest) { + const transaction = await this._getTransaction(_tx); + return logger.getBigInt(await this.#perform({ + method: "estimateGas", transaction + }), "%response"); + } + + async #call(tx: PerformActionTransaction, blockTag: string, attempt: number): Promise { + if (attempt >= MAX_CCIP_REDIRECTS) { + logger.throwError("CCIP read exceeded maximum redirections", "OFFCHAIN_FAULT", { + reason: "TOO_MANY_REDIRECTS", + transaction: Object.assign({ }, tx, { blockTag, enableCcipRead: true }) + }); + } + + const transaction = copyRequest(tx); + + try { + return hexlify(await this._perform({ method: "call", transaction, blockTag })); + + } catch (error) { + // CCIP Read OffchainLookup + if (!this.disableCcipRead && isCallException(error) && attempt >= 0 && blockTag === "latest" && transaction.to != null && dataSlice(error.data, 0, 4) === "0x556f1830") { + const data = error.data; + + const txSender = await resolveAddress(transaction.to, this); + + // Parse the CCIP Read Arguments + let ccipArgs: CcipArgs; + try { + ccipArgs = parseOffchainLookup(dataSlice(error.data, 4)); + } catch (error: any) { + return logger.throwError(error.message, "OFFCHAIN_FAULT", { + reason: "BAD_DATA", + transaction, info: { data } + }); + } + + // Check the sender of the OffchainLookup matches the transaction + if (ccipArgs.sender.toLowerCase() !== txSender.toLowerCase()) { + return logger.throwError("CCIP Read sender mismatch", "CALL_EXCEPTION", { + data, transaction, + errorSignature: "OffchainLookup(address,string[],bytes,bytes4,bytes)", + errorName: "OffchainLookup", + errorArgs: ccipArgs.errorArgs + }); + } + + const ccipResult = await this.ccipReadFetch(transaction, ccipArgs.calldata, ccipArgs.urls); + if (ccipResult == null) { + return logger.throwError("CCIP Read failed to fetch data", "OFFCHAIN_FAULT", { + reason: "FETCH_FAILED", + transaction, info: { data: error.data, errorArgs: ccipArgs.errorArgs } + }); + } + + return this.#call({ + to: txSender, + data: concat([ + ccipArgs.selector, encodeBytes([ ccipResult, ccipArgs.extraData ]) + ]), + }, blockTag, attempt + 1); + } + + throw error; + } + } + + async call(_tx: CallRequest) { + const [ tx, blockTag ] = await Promise.all([ + this._getTransaction(_tx), this._getBlockTag(_tx.blockTag) + ]); + return this.#call(tx, blockTag, _tx.enableCcipRead ? 0: -1); + } + + // Account + async #getAccountValue(request: _PerformAccountRequest, _address: AddressLike, _blockTag?: BlockTag): Promise { + let address: string | Promise = this._getAddress(_address); + let blockTag: string | Promise = this._getBlockTag(_blockTag); + + if (typeof(address) !== "string" || typeof(blockTag) !== "string") { + [ address, blockTag ] = await Promise.all([ address, blockTag ]); + } + + return await this.#perform(Object.assign(request, { address, blockTag })); + } + + async getBalance(address: AddressLike, blockTag?: BlockTag) { + return logger.getBigInt(await this.#getAccountValue({ method: "getBalance" }, address, blockTag), "%response"); + } + + async getTransactionCount(address: AddressLike, blockTag?: BlockTag) { + return logger.getNumber(await this.#getAccountValue({ method: "getTransactionCount" }, address, blockTag), "%response"); + } + + async getCode(address: AddressLike, blockTag?: BlockTag) { + return hexlify(await this.#getAccountValue({ method: "getCode" }, address, blockTag)); + } + + async getStorageAt(address: AddressLike, _position: BigNumberish, blockTag?: BlockTag) { + const position = logger.getBigInt(_position, "position"); + return hexlify(await this.#getAccountValue({ method: "getStorageAt", position }, address, blockTag)); + } + + // Write + async broadcastTransaction(signedTx: string) { + throw new Error(); + return { }; + } + + async #getBlock(block: BlockTag | string, includeTransactions: boolean): Promise { + if (isHexString(block, 32)) { + return await this.#perform({ + method: "getBlock", blockHash: block, includeTransactions + }); + } + + let blockTag = this._getBlockTag(block); + if (typeof(blockTag) !== "string") { blockTag = await blockTag; } + + return await this.#perform({ + method: "getBlock", blockTag, includeTransactions + }); + } + + // Queries + async getBlock(block: BlockTag | string): Promise> { + const [ network, params ] = await Promise.all([ + this.getNetwork(), this.#getBlock(block, false) + ]); + + if (params == null) { return null; } + + return network.formatter.block(params, this); + } + + async getBlockWithTransactions(block: BlockTag | string): Promise> { + const format = (await this.getNetwork()).formatter; + + const params = this.#getBlock(block, true); + if (params == null) { return null; } + + return format.blockWithTransactions(params, this); + } + + async getTransaction(hash: string): Promise { + const format = (await this.getNetwork()).formatter; + const params = await this.#perform({ method: "getTransaction", hash }); + return format.transactionResponse(params, this); + } + + async getTransactionReceipt(hash: string): Promise { + const format = (await this.getNetwork()).formatter; + + const receipt = await this.#perform({ method: "getTransactionReceipt", hash }); + if (receipt == null) { return null; } + + // Some backends did not backfill the effectiveGasPrice into old transactions + // in the receipt, so we look it up manually and inject it. + if (receipt.gasPrice == null && receipt.effectiveGasPrice == null) { + const tx = await this.#perform({ method: "getTransaction", hash }); + receipt.effectiveGasPrice = tx.gasPrice; + } + + return format.receipt(receipt, this); + } + + async getTransactionResult(hash: string): Promise { + const result = await this.#perform({ method: "getTransactionResult", hash }); + if (result == null) { return null; } + return hexlify(result); + } + + _getFilter(filter: Filter | FilterByBlockHash): PerformActionFilter | Promise { + + // Create a canonical representation of the topics + const topics = (filter.topics || [ ]).map((t) => { + if (t == null) { return null; } + if (Array.isArray(t)) { + return concisify(t.map((t) => t.toLowerCase())); + } + return t.toLowerCase(); + }); + + const blockHash = ("blockHash" in filter) ? filter.blockHash: undefined; + + const resolve = (_address: Array, fromBlock?: string, toBlock?: string) => { + let address: undefined | string | Array = undefined; + switch (_address.length) { + case 0: break; + case 1: + address = _address[0]; + break; + default: + _address.sort(); + address = _address; + } + + if (blockHash) { + if (fromBlock != null || toBlock != null) { + throw new Error("invalid filter"); + } + } + + const filter = { }; + if (address) { filter.address = address; } + if (topics.length) { filter.topics = topics; } + if (fromBlock) { filter.fromBlock = fromBlock; } + if (toBlock) { filter.toBlock = toBlock; } + if (blockHash) { filter.blockHash = blockHash; } + + return filter; + }; + + // Addresses could be async (ENS names or Addressables) + let address: Array> = [ ]; + if (filter.address) { + if (Array.isArray(filter.address)) { + for (const addr of filter.address) { address.push(this._getAddress(addr)); } + } else { + address.push(this._getAddress(filter.address)); + } + } + + let fromBlock: undefined | string | Promise = undefined; + if ("fromBlock" in filter) { fromBlock = this._getBlockTag(filter.fromBlock); } + + let toBlock: undefined | string | Promise = undefined; + if ("toBlock" in filter) { toBlock = this._getBlockTag(filter.toBlock); } + + if (address.filter((a) => (typeof(a) !== "string")).length || + (fromBlock != null && typeof(fromBlock) !== "string") || + (toBlock != null && typeof(toBlock) !== "string")) { + + return Promise.all([ Promise.all(address), fromBlock, toBlock ]).then((result) => { + return resolve(result[0], result[1], result[2]); + }); + } + + return resolve(>address, fromBlock, toBlock); + } + + // Bloom-filter Queries + async getLogs(_filter: Filter | FilterByBlockHash): Promise> { + const { network, filter } = await resolveProperties({ + network: this.getNetwork(), + filter: this._getFilter(_filter) + }); + + return (await this.#perform>({ method: "getLogs", filter })).map((l) => { + return network.formatter.log(l, this); + }); + } + + // ENS + _getProvider(chainId: number): AbstractProvider { + return logger.throwError("provider cannot connect to target network", "UNSUPPORTED_OPERATION", { + operation: "_getProvider()" + }); + } + + async getResolver(name: string): Promise { + return await EnsResolver.fromName(this, name); + } + + async getAvatar(name: string): Promise { + const resolver = await this.getResolver(name); + if (resolver) { return await resolver.getAvatar(); } + return null; + } + + async resolveName(name: string): Promise{ + //if (typeof(name) === "string") { + const resolver = await this.getResolver(name); + if (resolver) { return await resolver.getAddress(); } + /* + } else { + const address = await name.getAddress(); + if (address == null) { + return logger.throwArgumentError("Addressable returned no address", "name", name); + } + return address; + } + */ + return null; + } + + async lookupAddress(address: string): Promise { + throw new Error(); + //return "TODO"; + } + + async waitForTransaction(hash: string, confirms: number = 1, timeout?: number): Promise { + if (confirms === 0) { return this.getTransactionReceipt(hash); } + + return new Promise(async (resolve, reject) => { + let timer: null | NodeJS.Timer = null; + + const listener = (async (blockNumber: number) => { + try { + const receipt = await this.getTransactionReceipt(hash); + if (receipt != null) { + if (blockNumber - receipt.blockNumber + 1 >= confirms) { + resolve(receipt); + this.off("block", listener); + if (timer) { + clearTimeout(timer); + timer = null; + } + return; + } + } + } catch (error) { + console.log("EEE", error); + } + this.once("block", listener); + }); + + if (timeout != null) { + timer = setTimeout(() => { + if (timer == null) { return; } + timer = null; + this.off("block", listener); + reject(logger.makeError("timeout", "TIMEOUT", { reason: "timeout" })); + }, timeout); + } + + listener(await this.getBlockNumber()); + }); + } + + async waitForBlock(blockTag?: BlockTag): Promise> { + throw new Error(); + //return new Block({ }, this); + } + + _clearTimeout(timerId: number): void { + const timer = this.#timers.get(timerId); + if (!timer) { return; } + if (timer.timer) { clearTimeout(timer.timer); } + this.#timers.delete(timerId); + } + + _setTimeout(_func: () => void, timeout: number = 0): number { + const timerId = this.#nextTimer++; + const func = () => { + this.#timers.delete(timerId); + _func(); + }; + + if (this.paused) { + this.#timers.set(timerId, { timer: null, func, time: timeout }); + } else { + const timer = setTimeout(func, timeout); + this.#timers.set(timerId, { timer, func, time: getTime() }); + } + + return timerId; + } + + _forEachSubscriber(func: (s: Subscriber) => void): void { + for (const sub of this.#subs.values()) { + func(sub.subscriber); + } + } + + // Event API; sub-classes should override this; any supported + // event filter will have been munged into an EventFilter + _getSubscriber(sub: Subscription): Subscriber { + switch (sub.type) { + case "debug": + case "network": + return new UnmanagedSubscriber(sub.type); + case "block": + return new PollingBlockSubscriber(this); + case "event": + return new PollingEventSubscriber(this, sub.filter); + case "transaction": + return new PollingTransactionSubscriber(this, sub.hash); + case "orphan": + return new PollingOrphanSubscriber(this, sub.filter); + } + + throw new Error(`unsupported event: ${ sub.type }`); + } + + _recoverSubscriber(oldSub: Subscriber, newSub: Subscriber): void { + for (const sub of this.#subs.values()) { + if (sub.subscriber === oldSub) { + if (sub.started) { sub.subscriber.stop(); } + sub.subscriber = newSub; + if (sub.started) { newSub.start(); } + if (this.#pausedState != null) { newSub.pause(this.#pausedState); } + break; + } + } + } + + async #hasSub(event: ProviderEvent, emitArgs?: Array): Promise { + let sub = await getSubscription(event, this); + // This is a log that is removing an existing log; we actually want + // to emit an orphan event for the removed log + if (sub.type === "event" && emitArgs && emitArgs.length > 0 && emitArgs[0].removed === true) { + sub = await getSubscription({ orphan: "drop-log", log: emitArgs[0] }, this); + } + return this.#subs.get(sub.tag) || null; + } + + async #getSub(event: ProviderEvent): Promise { + const subscription = await getSubscription(event, this); + + // Prevent tampering with our tag in any subclass' _getSubscriber + const tag = subscription.tag; + + let sub = this.#subs.get(tag); + if (!sub) { + const subscriber = this._getSubscriber(subscription); + const addressableMap = new WeakMap(); + const nameMap = new Map(); + sub = { subscriber, tag, addressableMap, nameMap, started: false, listeners: [ ] }; + this.#subs.set(tag, sub); + } + return sub; + } + + async on(event: ProviderEvent, listener: Listener): Promise { + const sub = await this.#getSub(event); + sub.listeners.push({ listener, once: false }); + if (!sub.started) { + sub.subscriber.start(); + sub.started = true; + if (this.#pausedState != null) { sub.subscriber.pause(this.#pausedState); } + } + return this; + } + + async once(event: ProviderEvent, listener: Listener): Promise { + const sub = await this.#getSub(event); + sub.listeners.push({ listener, once: true }); + if (!sub.started) { + sub.subscriber.start(); + sub.started = true; + if (this.#pausedState != null) { sub.subscriber.pause(this.#pausedState); } + } + return this; + } + + async emit(event: ProviderEvent, ...args: Array): Promise { + const sub = await this.#hasSub(event, args); + if (!sub) { return false; }; + + const count = sub.listeners.length; + sub.listeners = sub.listeners.filter(({ listener, once }) => { + const payload = new EventPayload(this, (once ? null: listener), event); + try { + listener.call(this, ...args, payload); + } catch(error) { } + return !once; + }); + + return (count > 0); + } + + async listenerCount(event?: ProviderEvent): Promise { + if (event) { + const sub = await this.#hasSub(event); + if (!sub) { return 0; } + return sub.listeners.length; + } + + let total = 0; + for (const { listeners } of this.#subs.values()) { + total += listeners.length; + } + return total; + } + + async listeners(event?: ProviderEvent): Promise> { + if (event) { + const sub = await this.#hasSub(event); + if (!sub) { return [ ]; } + return sub.listeners.map(({ listener }) => listener); + } + let result: Array = [ ]; + for (const { listeners } of this.#subs.values()) { + result = result.concat(listeners.map(({ listener }) => listener)); + } + return result; + } + + async off(event: ProviderEvent, listener?: Listener): Promise { + const sub = await this.#hasSub(event); + if (!sub) { return this; } + + if (listener) { + const index = sub.listeners.map(({ listener }) => listener).indexOf(listener); + if (index >= 0) { sub.listeners.splice(index, 1); } + } + + if (!listener || sub.listeners.length === 0) { + if (sub.started) { sub.subscriber.stop(); } + this.#subs.delete(sub.tag); + } + + return this; + } + + async removeAllListeners(event?: ProviderEvent): Promise { + if (event) { + const { tag, started, subscriber } = await this.#getSub(event); + if (started) { subscriber.stop(); } + this.#subs.delete(tag); + } else { + for (const [ tag, { started, subscriber } ] of this.#subs) { + if (started) { subscriber.stop(); } + this.#subs.delete(tag); + } + } + return this; + } + + // Alias for "on" + async addListener(event: ProviderEvent, listener: Listener): Promise { + return await this.on(event, listener); + } + + // Alias for "off" + async removeListener(event: ProviderEvent, listener: Listener): Promise { + return this.off(event, listener); + } + + // Sub-classes should override this to shutdown any sockets, etc. + // but MUST call this super.shutdown. + async shutdown(): Promise { + // Stop all listeners + this.removeAllListeners(); + + // Shut down all tiemrs + for (const timerId of this.#timers.keys()) { + this._clearTimeout(timerId); + } + } + + get paused(): boolean { return (this.#pausedState != null); } + set paused(pause: boolean) { + if (!!pause === this.paused) { return; } + + if (this.paused) { + this.resume(); + } else { + this.pause(false); + } + } + + pause(dropWhilePaused?: boolean): void { + if (this.#pausedState != null) { + if (this.#pausedState == !!dropWhilePaused) { return; } + return logger.throwError("cannot change pause type; resume first", "UNSUPPORTED_OPERATION", { + operation: "pause" + }); + } + + this._forEachSubscriber((s) => s.pause(dropWhilePaused)); + this.#pausedState = !!dropWhilePaused; + + for (const timer of this.#timers.values()) { + // Clear the timer + if (timer.timer) { clearTimeout(timer.timer); } + + // Remaining time needed for when we become unpaused + timer.time = getTime() - timer.time; + } + } + + resume(): void { + if (this.#pausedState == null) { return; } + + this._forEachSubscriber((s) => s.resume()); + this.#pausedState = null; + for (const timer of this.#timers.values()) { + // Remaining time when we were paused + let timeout = timer.time; + if (timeout < 0) { timeout = 0; } + + // Start time (in cause paused, so we con compute remaininf time) + timer.time = getTime(); + + // Start the timer + setTimeout(timer.func, timeout); + } + } +} + + +function _parseString(result: string, start: number): null | string { + try { + const bytes = _parseBytes(result, start); + if (bytes) { return toUtf8String(bytes); } + } catch(error) { } + return null; +} + +function _parseBytes(result: string, start: number): null | string { + if (result === "0x") { return null; } + try { + const offset = logger.getNumber(dataSlice(result, start, start + 32)); + const length = logger.getNumber(dataSlice(result, offset, offset + 32)); + + return dataSlice(result, offset + 32, offset + 32 + length); + } catch (error) { } + return null; +} + +function numPad(value: number): Uint8Array { + const result = toArray(value); + if (result.length > 32) { throw new Error("internal; should not happen"); } + + const padded = new Uint8Array(32); + padded.set(result, 32 - result.length); + return padded; +} + +function bytesPad(value: Uint8Array): Uint8Array { + if ((value.length % 32) === 0) { return value; } + + const result = new Uint8Array(Math.ceil(value.length / 32) * 32); + result.set(value); + return result; +} + +const empty = new Uint8Array([ ]); + +// ABI Encodes a series of (bytes, bytes, ...) +function encodeBytes(datas: Array) { + const result: Array = [ ]; + + let byteCount = 0; + + // Add place-holders for pointers as we add items + for (let i = 0; i < datas.length; i++) { + result.push(empty); + byteCount += 32; + } + + for (let i = 0; i < datas.length; i++) { + const data = logger.getBytes(datas[i]); + + // Update the bytes offset + result[i] = numPad(byteCount); + + // The length and padded value of data + result.push(numPad(data.length)); + result.push(bytesPad(data)); + byteCount += 32 + Math.ceil(data.length / 32) * 32; + } + + return concat(result); +} + +const zeros = "0x0000000000000000000000000000000000000000000000000000000000000000" +function parseOffchainLookup(data: string): CcipArgs { + const result: CcipArgs = { + sender: "", urls: [ ], calldata: "", selector: "", extraData: "", errorArgs: [ ] + }; + + if (dataLength(data) < 5 * 32) { + throw new Error("insufficient OffchainLookup data"); + } + + const sender = dataSlice(data, 0, 32); + if (dataSlice(sender, 0, 12) !== dataSlice(zeros, 0, 12)) { + throw new Error("corrupt OffchainLookup sender"); + } + result.sender = dataSlice(sender, 12); + + // Read the URLs from the response + try { + const urls: Array = []; + const urlsOffset = logger.getNumber(dataSlice(data, 32, 64)); + const urlsLength = logger.getNumber(dataSlice(data, urlsOffset, urlsOffset + 32)); + const urlsData = dataSlice(data, urlsOffset + 32); + for (let u = 0; u < urlsLength; u++) { + const url = _parseString(urlsData, u * 32); + if (url == null) { throw new Error("abort"); } + urls.push(url); + } + result.urls = urls; + } catch (error) { + throw new Error("corrupt OffchainLookup urls"); + } + + // Get the CCIP calldata to forward + try { + const calldata = _parseBytes(data, 64); + if (calldata == null) { throw new Error("abort"); } + result.calldata = calldata; + } catch (error) { throw new Error("corrupt OffchainLookup calldata"); } + + // Get the callbackSelector (bytes4) + if (dataSlice(data, 100, 128) !== dataSlice(zeros, 0, 28)) { + throw new Error("corrupt OffchainLookup callbaackSelector"); + } + result.selector = dataSlice(data, 96, 100); + + // Get the extra data to send back to the contract as context + try { + const extraData = _parseBytes(data, 128); + if (extraData == null) { throw new Error("abort"); } + result.extraData = extraData; + } catch (error) { throw new Error("corrupt OffchainLookup extraData"); } + + result.errorArgs = "sender,urls,calldata,selector,extraData".split(/,/).map((k) => (result)[k]) + + + return result; +} diff --git a/src.ts/providers/abstract-signer.ts b/src.ts/providers/abstract-signer.ts new file mode 100644 index 000000000..757cf939d --- /dev/null +++ b/src.ts/providers/abstract-signer.ts @@ -0,0 +1,199 @@ +import { logger } from "../utils/logger.js"; +import { Transaction } from "../transaction/index.js"; +import { defineProperties, resolveProperties } from "../utils/index.js"; + +import type { TypedDataDomain, TypedDataField } from "../hash/index.js"; +import type { TransactionLike } from "../transaction/index.js"; + +import type { + BlockTag, CallRequest, Provider, TransactionRequest, TransactionResponse +} from "./provider.js"; +import type { Signer } from "./signer.js"; + + +export abstract class AbstractSigner

implements Signer { + readonly provider!: P; + + constructor(provider?: P) { + defineProperties(this, { provider: (provider || null) }); + } + + abstract getAddress(): Promise; + abstract connect(provider: null | Provider): Signer; + + #checkProvider(operation: string): Provider { + if (this.provider) { return this.provider; } + return logger.throwError("missing provider", "UNSUPPORTED_OPERATION", { operation }); + } + + async getNonce(blockTag?: BlockTag): Promise { + return this.#checkProvider("getTransactionCount").getTransactionCount(await this.getAddress(), blockTag); + } + + async #populate(op: string, tx: CallRequest | TransactionRequest): Promise> { + const provider = this.#checkProvider(op); + + //let pop: Deferrable = Object.assign({ }, tx); + let pop: any = Object.assign({ }, tx); + + if (pop.to != null) { + pop.to = provider.resolveName(pop.to).then((to) => { + if (to == null) { + return logger.throwArgumentError("transaction to ENS name not configured", "tx.to", pop.to); + } + return to; + }); + } + + if (pop.from != null) { + const from = pop.from; + pop.from = Promise.all([ + this.getAddress(), + this.resolveName(from) + ]).then(([ address, from ]) => { + if (!from || address.toLowerCase() !== from.toLowerCase()) { + return logger.throwArgumentError("transaction from mismatch", "tx.from", from); + } + return address; + }); + } + + return pop; + } + + async populateCall(tx: CallRequest): Promise> { + const pop = await this.#populate("populateCall", tx); + + return pop; + } + + async populateTransaction(tx: TransactionRequest): Promise> { + const pop = await this.#populate("populateTransaction", tx); + + if (pop.nonce == null) { + pop.nonce = await this.getNonce("pending"); + } + + if (pop.gasLimit == null) { + pop.gasLimit = await this.estimateGas(pop); + } + + //@TODO: Copy type logic from AbstractSigner in v5 + + return await resolveProperties(pop); + } + + async estimateGas(tx: CallRequest): Promise { + return this.#checkProvider("estimateGas").estimateGas(await this.populateCall(tx)); + } + + async call(tx: CallRequest): Promise { + return this.#checkProvider("call").call(await this.populateCall(tx)); + } + + async resolveName(name: string): Promise { + const provider = this.#checkProvider("resolveName"); + return await provider.resolveName(name); + } + + async sendTransaction(tx: TransactionRequest): Promise { + const provider = this.#checkProvider("sendTransaction"); + + const txObj = Transaction.from(await this.populateTransaction(tx)); + return await provider.broadcastTransaction(await this.signTransaction(txObj)); + } + + abstract signTransaction(tx: TransactionRequest): Promise; + abstract signMessage(message: string | Uint8Array): Promise; + abstract signTypedData(domain: TypedDataDomain, types: Record>, value: Record): Promise; +} + +export class VoidSigner extends AbstractSigner { + readonly address!: string; + + constructor(address: string, provider?: null | Provider) { + super(provider); + defineProperties(this, { address }); + } + + async getAddress(): Promise { return this.address; } + + connect(provider: null | Provider): VoidSigner { + return new VoidSigner(this.address, provider); + } + + #throwUnsupported(suffix: string, operation: string): never { + return logger.throwError(`VoidSigner cannot sign ${ suffix }`, "UNSUPPORTED_OPERATION", { + operation + }); + } + + async signTransaction(tx: TransactionRequest): Promise { + this.#throwUnsupported("transactions", "signTransaction"); + } + + async signMessage(message: string | Uint8Array): Promise { + this.#throwUnsupported("messages", "signMessage"); + } + + async signTypedData(domain: TypedDataDomain, types: Record>, value: Record): Promise { + this.#throwUnsupported("typed-data", "signTypedData"); + } +} + +export class WrappedSigner extends AbstractSigner { + #signer: Signer; + + constructor(signer: Signer) { + super(signer.provider); + this.#signer = signer; + } + + async getAddress(): Promise { + return await this.#signer.getAddress(); + } + + connect(provider: null | Provider): WrappedSigner { + return new WrappedSigner(this.#signer.connect(provider)); + } + + async getNonce(blockTag?: BlockTag): Promise { + return await this.#signer.getNonce(blockTag); + } + + async populateCall(tx: CallRequest): Promise> { + return await this.#signer.populateCall(tx); + } + + async populateTransaction(tx: TransactionRequest): Promise> { + return await this.#signer.populateTransaction(tx); + } + + async estimateGas(tx: CallRequest): Promise { + return await this.#signer.estimateGas(tx); + } + + async call(tx: CallRequest): Promise { + return await this.#signer.call(tx); + } + + async resolveName(name: string): Promise { + return this.#signer.resolveName(name); + } + + async signTransaction(tx: TransactionRequest): Promise { + return await this.#signer.signTransaction(tx); + } + + async sendTransaction(tx: TransactionRequest): Promise { + return await this.#signer.sendTransaction(tx); + } + + async signMessage(message: string | Uint8Array): Promise { + return await this.#signer.signMessage(message); + } + + async signTypedData(domain: TypedDataDomain, types: Record>, value: Record): Promise { + return await this.#signer.signTypedData(domain, types, value); + } +} diff --git a/src.ts/providers/common-networks.ts b/src.ts/providers/common-networks.ts new file mode 100644 index 000000000..99082fe66 --- /dev/null +++ b/src.ts/providers/common-networks.ts @@ -0,0 +1,102 @@ + +/** + * Exports the same Network as "./network.js" except with common + * networks injected registered. + */ + +import { EnsPlugin, GasCostPlugin } from "./plugins-network.js"; +import { EtherscanPlugin } from "./provider-etherscan.js"; + +import { Network } from "./network.js"; + +type Options = { + ensNetwork?: number; + priorityFee?: number + altNames?: Array; + etherscan?: { url: string, apiKey: string }; +}; + +// See: https://chainlist.org +export function injectCommonNetworks(): void { + + /// Register popular Ethereum networks + function registerEth(name: string, chainId: number, options: Options): void { + const func = function() { + const network = new Network(name, chainId); + + // We use 0 to disable ENS + if (options.ensNetwork != null) { + network.attachPlugin(new EnsPlugin(null, options.ensNetwork)); + } + + if (options.priorityFee) { +// network.attachPlugin(new MaxPriorityFeePlugin(options.priorityFee)); + } + + if (options.etherscan) { + const { url, apiKey } = options.etherscan; + network.attachPlugin(new EtherscanPlugin(url, apiKey)); + } + + network.attachPlugin(new GasCostPlugin()); + + return network; + }; + + // Register the network by name and chain ID + Network.register(name, func); + Network.register(chainId, func); + + if (options.altNames) { + options.altNames.forEach((name) => { + Network.register(name, func); + }); + } + } + + registerEth("homestead", 1, { ensNetwork: 1, altNames: [ "mainnet" ] }); + registerEth("ropsten", 3, { ensNetwork: 3 }); + registerEth("rinkeby", 4, { ensNetwork: 4 }); + registerEth("goerli", 5, { ensNetwork: 5 }); + registerEth("kovan", 42, { ensNetwork: 42 }); + + registerEth("classic", 61, { }); + registerEth("classicKotti", 6, { }); + + registerEth("xdai", 100, { ensNetwork: 1 }); + + // Polygon has a 35 gwei maxPriorityFee requirement + registerEth("matic", 137, { + ensNetwork: 1, +// priorityFee: 35000000000, + etherscan: { + apiKey: "W6T8DJW654GNTQ34EFEYYP3EZD9DD27CT7", + url: "https:/\/api.polygonscan.com/" + } + }); + registerEth("maticMumbai", 80001, { +// priorityFee: 35000000000, + etherscan: { + apiKey: "W6T8DJW654GNTQ34EFEYYP3EZD9DD27CT7", + url: "https:/\/api-testnet.polygonscan.com/" + } + }); + + registerEth("bnb", 56, { + ensNetwork: 1, + etherscan: { + apiKey: "EVTS3CU31AATZV72YQ55TPGXGMVIFUQ9M9", + url: "http:/\/api.bscscan.com" + } + }); + registerEth("bnbt", 97, { + etherscan: { + apiKey: "EVTS3CU31AATZV72YQ55TPGXGMVIFUQ9M9", + url: "http:/\/api-testnet.bscscan.com" + } + }); +} + +injectCommonNetworks(); + +export { Network }; diff --git a/src.ts/providers/community.ts b/src.ts/providers/community.ts new file mode 100644 index 000000000..dddd1a21a --- /dev/null +++ b/src.ts/providers/community.ts @@ -0,0 +1,24 @@ + +export interface CommunityResourcable { + isCommunityResource(): boolean; +} + +// Show the throttle message only once +const shown: Set = new Set(); +export function showThrottleMessage(service: string) { + if (shown.has(service)) { return; } + shown.add(service); + + console.log("========= NOTICE =========") + console.log(`Request-Rate Exceeded for ${ service } (this message will not be repeated)`); + console.log(""); + console.log("The default API keys for each service are provided as a highly-throttled,"); + console.log("community resource for low-traffic projects and early prototyping."); + console.log(""); + console.log("While your application will continue to function, we highly recommended"); + console.log("signing up for your own API keys to improve performance, increase your"); + console.log("request rate/limit and enable other perks, such as metrics and advanced APIs."); + console.log(""); + console.log("For more details: https:/\/docs.ethers.io/api-keys/"); + console.log("=========================="); +} diff --git a/src.ts/providers/contracts.ts b/src.ts/providers/contracts.ts new file mode 100644 index 000000000..312fc80b8 --- /dev/null +++ b/src.ts/providers/contracts.ts @@ -0,0 +1,21 @@ +import type { + CallRequest, Provider, TransactionRequest, TransactionResponse +} from "./provider.js"; + +// The object that will be used to run Contracts. The Signer and Provider +// both adhere to this, but other types of objects may wish to as well. +export interface ContractRunner { + provider: null | Provider; + + // Required to estimate gas; usually a Signer or Provider + estimateGas?: (tx: TransactionRequest) => Promise; + + // Required for pure, view or static calls to contracts; usually a Signer or Provider + call?: (tx: CallRequest) => Promise; + + // Required to support ENS names; usually a Signer or Provider + resolveName?: (name: string) => Promise; + + // Required for mutating calls; usually a Signer + sendTransaction?: (tx: TransactionRequest) => Promise; +} diff --git a/src.ts/providers/default-provider.ts b/src.ts/providers/default-provider.ts new file mode 100644 index 000000000..9673c1306 --- /dev/null +++ b/src.ts/providers/default-provider.ts @@ -0,0 +1,89 @@ + +import { AnkrProvider } from "./provider-ankr.js"; +import { AlchemyProvider } from "./provider-alchemy.js"; +import { CloudflareProvider } from "./provider-cloudflare.js"; +import { EtherscanProvider } from "./provider-etherscan.js"; +import { InfuraProvider } from "./provider-infura.js"; +//import { PocketProvider } from "./provider-pocket.js"; + +import { FallbackProvider } from "./provider-fallback.js"; +import { JsonRpcProvider } from "./provider-jsonrpc.js"; +import { WebSocketProvider } from "./provider-websocket.js"; + +import type { AbstractProvider } from "./abstract-provider.js"; +import type { Networkish } from "./network.js"; +import { WebSocketLike } from "./provider-websocket.js"; + +function isWebSocketLike(value: any): value is WebSocketLike { + return (value && typeof(value.send) === "function" && + typeof(value.close) === "function"); +} + +export function getDefaultProvider(network: string | Networkish | WebSocketLike, options?: any): AbstractProvider { + if (options == null) { options = { }; } + + if (typeof(network) === "string" && network.match(/^https?:/)) { + return new JsonRpcProvider(network); + } + + if (typeof(network) === "string" && network.match(/^wss?:/) || isWebSocketLike(network)) { + return new WebSocketProvider(network); + } + + const providers: Array = [ ]; + + if (options.alchemy !== "-") { + try { + providers.push(new AlchemyProvider(network, options.alchemy)); + } catch (error) { console.log(error); } + } + + if (options.ankr !== "-") { + try { + providers.push(new AnkrProvider(network, options.ankr)); + } catch (error) { console.log(error); } + } + + if (options.cloudflare !== "-") { + try { + providers.push(new CloudflareProvider(network)); + } catch (error) { console.log(error); } + } + + if (options.etherscan !== "-") { + try { + providers.push(new EtherscanProvider(network, options.etherscan)); + } catch (error) { console.log(error); } + } + + if (options.infura !== "-") { + try { + let projectId = options.infura; + let projectSecret: undefined | string = undefined; + if (typeof(projectId) === "object") { + projectSecret = projectId.projectSecret; + projectId = projectId.projectId; + } + providers.push(new InfuraProvider(network, projectId, projectSecret)); + } catch (error) { console.log(error); } + } +/* + if (options.pocket !== "-") { + try { + let appId = options.pocket; + let secretKey: undefined | string = undefined; + let loadBalancer: undefined | boolean = undefined; + if (typeof(appId) === "object") { + loadBalancer = !!appId.loadBalancer; + secretKey = appId.secretKey; + appId = appId.appId; + } + providers.push(new PocketProvider(network, appId, secretKey, loadBalancer)); + } catch (error) { console.log(error); } + } +*/ + if (providers.length === 0) { throw new Error("TODO"); } + if (providers.length === 1) { return providers[0]; } + + return new FallbackProvider(providers); +} diff --git a/src.ts/providers/ens-resolver.ts b/src.ts/providers/ens-resolver.ts new file mode 100644 index 000000000..1c1899fcd --- /dev/null +++ b/src.ts/providers/ens-resolver.ts @@ -0,0 +1,535 @@ +import { ZeroHash } from "../constants/hashes.js"; +import { dnsEncode, namehash } from "../hash/index.js"; +import { + defineProperties, encodeBase58, toArray, toNumber, toUtf8Bytes, toUtf8String +} from "../utils/index.js"; +import { concat, dataSlice, hexlify, zeroPadValue } from "../utils/data.js"; +import { FetchRequest } from "../utils/fetch.js"; +import { logger } from "../utils/logger.js"; + +import type { BigNumberish, BytesLike, EthersError } from "../utils/index.js"; + +import type { AbstractProvider, ProviderPlugin } from "./abstract-provider.js"; +import type { EnsPlugin } from "./plugins-network.js"; +import type { CallRequest, Provider } from "./provider.js"; + +const BN_1 = BigInt(1); + +const Empty = new Uint8Array([ ]); + +function parseBytes(result: string, start: number): null | string { + if (result === "0x") { return null; } + + const offset = toNumber(dataSlice(result, start, start + 32)); + const length = toNumber(dataSlice(result, offset, offset + 32)); + + return dataSlice(result, offset + 32, offset + 32 + length); +} + +function parseString(result: string, start: number): null | string { + try { + const bytes = parseBytes(result, start); + if (bytes != null) { return toUtf8String(bytes); } + } catch(error) { } + return null; +} + +function numPad(value: BigNumberish): Uint8Array { + const result = toArray(value); + if (result.length > 32) { throw new Error("internal; should not happen"); } + + const padded = new Uint8Array(32); + padded.set(result, 32 - result.length); + return padded; +} + +function bytesPad(value: Uint8Array): Uint8Array { + if ((value.length % 32) === 0) { return value; } + + const result = new Uint8Array(Math.ceil(value.length / 32) * 32); + result.set(value); + return result; +} + +// ABI Encodes a series of (bytes, bytes, ...) +function encodeBytes(datas: Array) { + const result: Array = [ ]; + + let byteCount = 0; + + // Add place-holders for pointers as we add items + for (let i = 0; i < datas.length; i++) { + result.push(Empty); + byteCount += 32; + } + + for (let i = 0; i < datas.length; i++) { + const data = logger.getBytes(datas[i]); + + // Update the bytes offset + result[i] = numPad(byteCount); + + // The length and padded value of data + result.push(numPad(data.length)); + result.push(bytesPad(data)); + byteCount += 32 + Math.ceil(data.length / 32) * 32; + } + + return concat(result); +} + +// @TODO: This should use the fetch-data:ipfs gateway +// Trim off the ipfs:// prefix and return the default gateway URL +function getIpfsLink(link: string): string { + if (link.match(/^ipfs:\/\/ipfs\//i)) { + link = link.substring(12); + } else if (link.match(/^ipfs:\/\//i)) { + link = link.substring(7); + } else { + logger.throwArgumentError("unsupported IPFS format", "link", link); + } + + return `https:/\/gateway.ipfs.io/ipfs/${ link }`; +} + +export type AvatarLinkageType = "name" | "avatar" | "!avatar" | "url" | "data" | "ipfs" | + "erc721" | "erc1155" | "!erc721-caip" | "!erc1155-caip" | + "!owner" | "owner" | "!balance" | "balance" | + "metadata-url-base" | "metadata-url-expanded" | "metadata-url" | "!metadata-url" | + "!metadata" | "metadata" | + "!imageUrl" | "imageUrl-ipfs" | "imageUrl" | "!imageUrl-ipfs"; + +export interface AvatarLinkage { + type: AvatarLinkageType; + value: string; +}; + +export interface AvatarResult { + linkage: Array; + url: null | string; +}; + +export abstract class MulticoinProviderPlugin implements ProviderPlugin { + readonly name!: string; + + constructor(name: string) { + defineProperties(this, { name }); + } + + validate(proivder: Provider): ProviderPlugin { + return this; + } + + supportsCoinType(coinType: number): boolean { + return false; + } + + async encodeAddress(coinType: number, address: string): Promise { + throw new Error("unsupported coin"); + } + + async decodeAddress(coinType: number, data: BytesLike): Promise { + throw new Error("unsupported coin"); + } +} + +const BasicMulticoinPluginId = "org.ethers.provider-prugins.basicmulticoin"; + +export class BasicMulticoinProviderPlugin extends MulticoinProviderPlugin { + constructor() { + super(BasicMulticoinPluginId); + } +} + +const matcherIpfs = new RegExp("^(ipfs):/\/(.*)$", "i"); +const matchers = [ + new RegExp("^(https):/\/(.*)$", "i"), + new RegExp("^(data):(.*)$", "i"), + matcherIpfs, + new RegExp("^eip155:[0-9]+/(erc[0-9]+):(.*)$", "i"), +]; + +export class EnsResolver { + provider!: AbstractProvider; + address!: string; + + name!: string; + + // For EIP-2544 names, the ancestor that provided the resolver + #supports2544: null | Promise; + + constructor(provider: AbstractProvider, address: string, name: string) { + defineProperties(this, { provider, address, name }); + this.#supports2544 = null; + } + + async supportsWildcard(): Promise { + if (!this.#supports2544) { + // supportsInterface(bytes4 = selector("resolve(bytes,bytes)")) + this.#supports2544 = this.provider.call({ + to: this.address, + data: "0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000" + }).then((result) => { + return (logger.getBigInt(result) === BN_1); + }).catch((error) => { + if (error.code === "CALL_EXCEPTION") { return false; } + // Rethrow the error: link is down, etc. Let future attempts retry. + this.#supports2544 = null; + throw error; + }); + } + + return await this.#supports2544; + } + + async _fetch(selector: string, parameters: BytesLike = "0x"): Promise { + + // e.g. keccak256("addr(bytes32,uint256)") + const addrData = concat([ selector, namehash(this.name), parameters ]); + const tx: CallRequest = { + to: this.address, + enableCcipRead: true, + data: addrData + }; + + // Wildcard support; use EIP-2544 to resolve the request + let wrapped = false; + if (await this.supportsWildcard()) { + wrapped = true; + + // selector("resolve(bytes,bytes)") + tx.data = concat([ "0x9061b923", encodeBytes([ dnsEncode(this.name), addrData ]) ]); + } + + try { + let data = await this.provider.call(tx); + if ((logger.getBytes(data).length % 32) === 4) { + return logger.throwError("resolver threw error", "CALL_EXCEPTION", { + transaction: tx, data + }); + } + if (wrapped) { return parseBytes(data, 0); } + return data; + } catch (error: any) { + if ((error as EthersError).code !== "CALL_EXCEPTION") { throw error; } + } + + return null; + } + + async getAddress(coinType: number = 60): Promise { + if (coinType === 60) { + try { + // keccak256("addr(bytes32)") + const result = await this._fetch("0x3b3b57de"); + + // No address + if (result === "0x" || result === ZeroHash) { return null; } + + const network = await this.provider.getNetwork(); + return network.formatter.callAddress(result); + } catch (error: any) { + if ((error as EthersError).code === "CALL_EXCEPTION") { return null; } + throw error; + } + } + + let coinPlugin: null | MulticoinProviderPlugin = null; + for (const plugin of this.provider.plugins) { + if (!(plugin instanceof MulticoinProviderPlugin)) { continue; } + if (plugin.supportsCoinType(coinType)) { + coinPlugin = plugin; + break; + } + } + + if (coinPlugin == null) { return null; } + + // keccak256("addr(bytes32,uint256") + const data = parseBytes((await this._fetch("0xf1cb7e06", numPad(coinType))) || "0x", 0); + + // No address + if (data == null || data === "0x") { return null; } + + // Compute the address + const address = await coinPlugin.encodeAddress(coinType, data); + + if (address != null) { return address; } + + return logger.throwError(`invalid coin data`, "UNSUPPORTED_OPERATION", { + operation: `getAddress(${ coinType })`, + info: { coinType, data } + }); + } + + async getText(key: string): Promise { + // The key encoded as parameter to fetchBytes + let keyBytes = toUtf8Bytes(key); + + // The nodehash consumes the first slot, so the string pointer targets + // offset 64, with the length at offset 64 and data starting at offset 96 + const calldata = logger.getBytes(concat([ numPad(64), numPad(keyBytes.length), keyBytes ])); + + const hexBytes = parseBytes((await this._fetch("0x59d1d43c", bytesPad(calldata))) || "0x", 0); + if (hexBytes == null || hexBytes === "0x") { return null; } + + return toUtf8String(hexBytes); + } + + async getContentHash(): Promise { + // keccak256("contenthash()") + const hexBytes = parseBytes((await this._fetch("0xbc1c58d1")) || "0x", 0); + + // No contenthash + if (hexBytes == null || hexBytes === "0x") { return null; } + + // IPFS (CID: 1, Type: 70=DAG-PB, 72=libp2p-key) + const ipfs = hexBytes.match(/^0x(e3010170|e5010172)(([0-9a-f][0-9a-f])([0-9a-f][0-9a-f])([0-9a-f]*))$/); + if (ipfs) { + const scheme = (ipfs[1] === "e3010170") ? "ipfs": "ipns"; + const length = parseInt(ipfs[4], 16); + if (ipfs[5].length === length * 2) { + return `${ scheme }:/\/${ encodeBase58("0x" + ipfs[2])}`; + } + } + + // Swarm (CID: 1, Type: swarm-manifest; hash/length hard-coded to keccak256/32) + const swarm = hexBytes.match(/^0xe40101fa011b20([0-9a-f]*)$/) + if (swarm && swarm[1].length === 64) { + return `bzz:/\/${ swarm[1] }`; + } + + return logger.throwError(`invalid or unsupported content hash data`, "UNSUPPORTED_OPERATION", { + operation: "getContentHash()", + info: { data: hexBytes } + }); + } + + async getAvatar(): Promise { + return (await this._getAvatar()).url; + } + + async _getAvatar(): Promise { + const linkage: Array = [ { type: "name", value: this.name } ]; + try { + // test data for ricmoo.eth + //const avatar = "eip155:1/erc721:0x265385c7f4132228A0d54EB1A9e7460b91c0cC68/29233"; + const avatar = await this.getText("avatar"); + if (avatar == null) { + linkage.push({ type: "!avatar", value: "" }); + throw new Error("!avatar"); + } + linkage.push({ type: "avatar", value: avatar }); + + for (let i = 0; i < matchers.length; i++) { + const match = avatar.match(matchers[i]); + if (match == null) { continue; } + + const scheme = match[1].toLowerCase(); + + switch (scheme) { + case "https": + case "data": + linkage.push({ type: "url", value: avatar }); + return { linkage, url: avatar }; + case "ipfs": { + const url = getIpfsLink(avatar); + linkage.push({ type: "ipfs", value: avatar }); + linkage.push({ type: "url", value: url }); + return { linkage, url }; + } + + case "erc721": + case "erc1155": { + // Depending on the ERC type, use tokenURI(uint256) or url(uint256) + const selector = (scheme === "erc721") ? "0xc87b56dd": "0x0e89341c"; + linkage.push({ type: scheme, value: avatar }); + + // The owner of this name + const owner = await this.getAddress(); + if (owner == null) { + linkage.push({ type: "!owner", value: "" }); + throw new Error("!owner"); + } + + const comps = (match[2] || "").split("/"); + if (comps.length !== 2) { + linkage.push({ type: `!${ scheme }caip`, value: (match[2] || "") }); + throw new Error("!caip"); + } + + const formatter = (await this.provider.getNetwork()).formatter; + + const addr = formatter.address(comps[0]); + const tokenId = numPad(comps[1]); + + // Check that this account owns the token + if (scheme === "erc721") { + // ownerOf(uint256 tokenId) + const tokenOwner = formatter.callAddress(await this.provider.call({ + to: addr, data: concat([ "0x6352211e", tokenId ]) + })); + if (owner !== tokenOwner) { + linkage.push({ type: "!owner", value: tokenOwner }); + throw new Error("!owner"); + } + linkage.push({ type: "owner", value: tokenOwner }); + + } else if (scheme === "erc1155") { + // balanceOf(address owner, uint256 tokenId) + const balance = logger.getBigInt(await this.provider.call({ + to: addr, data: concat([ "0x00fdd58e", zeroPadValue(owner, 32), tokenId ]) + })); + if (!balance) { + linkage.push({ type: "!balance", value: "0" }); + throw new Error("!balance"); + } + linkage.push({ type: "balance", value: balance.toString() }); + } + + // Call the token contract for the metadata URL + const tx = { + to: comps[0], + data: concat([ selector, tokenId ]) + }; + + let metadataUrl = parseString(await this.provider.call(tx), 0); + if (metadataUrl == null) { + linkage.push({ type: "!metadata-url", value: "" }); + throw new Error("!metadata-url"); + } + + linkage.push({ type: "metadata-url-base", value: metadataUrl }); + + // ERC-1155 allows a generic {id} in the URL + if (scheme === "erc1155") { + metadataUrl = metadataUrl.replace("{id}", hexlify(tokenId).substring(2)); + linkage.push({ type: "metadata-url-expanded", value: metadataUrl }); + } + + // Transform IPFS metadata links + if (metadataUrl.match(/^ipfs:/i)) { + metadataUrl = getIpfsLink(metadataUrl); + } + linkage.push({ type: "metadata-url", value: metadataUrl }); + + // Get the token metadata + let metadata: any = { }; + const response = await (new FetchRequest(metadataUrl)).send(); + response.assertOk(); + + try { + metadata = response.bodyJson; + } catch (error) { + try { + linkage.push({ type: "!metadata", value: response.bodyText }); + } catch (error) { + const bytes = response.body; + if (bytes) { + linkage.push({ type: "!metadata", value: hexlify(bytes) }); + } + throw error; + } + throw error; + } + + if (!metadata) { + linkage.push({ type: "!metadata", value: "" }); + throw new Error("!metadata"); + } + + linkage.push({ type: "metadata", value: JSON.stringify(metadata) }); + + // Pull the image URL out + let imageUrl = metadata.image; + if (typeof(imageUrl) !== "string") { + linkage.push({ type: "!imageUrl", value: "" }); + throw new Error("!imageUrl"); + } + + if (imageUrl.match(/^(https:\/\/|data:)/i)) { + // Allow + } else { + // Transform IPFS link to gateway + const ipfs = imageUrl.match(matcherIpfs); + if (ipfs == null) { + linkage.push({ type: "!imageUrl-ipfs", value: imageUrl }); + throw new Error("!imageUrl-ipfs"); + } + + linkage.push({ type: "imageUrl-ipfs", value: imageUrl }); + imageUrl = getIpfsLink(imageUrl); + } + + linkage.push({ type: "url", value: imageUrl }); + + return { linkage, url: imageUrl }; + } + } + } + } catch (error) { console.log("EE", error); } + + return { linkage, url: null }; + } + + static async #getResolver(provider: Provider, name: string): Promise { + const network = await provider.getNetwork(); + + const ensPlugin = network.getPlugin("org.ethers.network-plugins.ens"); + + // No ENS... + if (!ensPlugin) { + return logger.throwError("network does not support ENS", "UNSUPPORTED_OPERATION", { + operation: "getResolver", info: { network: network.name } + }); + } + + try { + // keccak256("resolver(bytes32)") + const addrData = await provider.call({ + to: ensPlugin.address, + data: concat([ "0x0178b8bf", namehash(name) ]), + enableCcipRead: true + }); + + const addr = network.formatter.callAddress(addrData); + if (addr === dataSlice(ZeroHash, 0, 20)) { return null; } + return addr; + + } catch (error) { + // ENS registry cannot throw errors on resolver(bytes32), + // so probably a link error + throw error; + } + + return null; + } + + static async fromName(provider: AbstractProvider, name: string): Promise { + + let currentName = name; + while (true) { + if (currentName === "" || currentName === ".") { return null; } + + // Optimization since the eth node cannot change and does + // not have a wildcar resolver + if (name !== "eth" && currentName === "eth") { return null; } + + // Check the current node for a resolver + const addr = await EnsResolver.#getResolver(provider, currentName); + + // Found a resolver! + if (addr != null) { + const resolver = new EnsResolver(provider, addr, name); + + // Legacy resolver found, using EIP-2544 so it isn't safe to use + if (currentName !== name && !(await resolver.supportsWildcard())) { return null; } + + return resolver; + } + + // Get the parent node + currentName = currentName.split(".").slice(1).join("."); + } + } +} diff --git a/src.ts/providers/formatter.ts b/src.ts/providers/formatter.ts new file mode 100644 index 000000000..2e536887c --- /dev/null +++ b/src.ts/providers/formatter.ts @@ -0,0 +1,442 @@ +// Belongs to Networks; requires abstract-provider +// provider requires abstract-provider and network + +/** + * Formatter + * + * This is responsibile for converting much of the various + * loose network values into a concrete ethers-ready value. + * + * For example, converting addresses to checksum addresses, + * validating a hash is 32 bytes, and so on. + * + * By sub-classing this class and providing it in a custom + * Network object this allows exotic (non-Ethereum) networks + * to be fairly simple to adapt to ethers. + */ + +import { getAddress, getCreateAddress } from "../address/index.js"; +import { dataLength, dataSlice, isHexString } from "../utils/data.js"; +import { toQuantity } from "../utils/maths.js"; +import { logger } from "../utils/logger.js"; +import { Signature } from "../crypto/signature.js"; +import { accessListify } from "../transaction/index.js"; + +import { Block, Log, TransactionReceipt, TransactionResponse } from "./provider.js"; + +import type { AccessList } from "../transaction/index.js"; + +import type { PerformActionTransaction } from "./abstract-provider.js"; +import type { Filter, Provider } from "./provider.js"; + + +const BN_MAX_UINT256 = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + +export type FormatFunc = (value: any) => any; + +//export type AccessListSet = { address: string, storageKeys: Array }; +//export type AccessList = Array; + +//export type AccessListish = AccessList | +// Array<[ string, Array ]> | +// Record>; + +function stringify(value: any): string { + if (typeof(value) !== "string") { throw new Error("invalid string"); } + return value; +} + +export class Formatter { + #format: { + address: FormatFunc, + bigNumber: FormatFunc, + blockTag: FormatFunc, + data: FormatFunc, + filter: FormatFunc, + hash: FormatFunc, + number: FormatFunc, + topics: FormatFunc, + transactionRequest: FormatFunc, + transactionResponse: FormatFunc, + uint256: FormatFunc, + }; + + #baseBlock: FormatFunc; + + constructor() { + const address = this.address.bind(this); + const bigNumber = this.bigNumber.bind(this); + const blockTag = this.blockTag.bind(this); + const data = this.data.bind(this); + const hash = this.hash.bind(this); + const number = this.number.bind(this); + const uint256 = this.uint256.bind(this); + + const topics = this.arrayOf(hash); + + this.#format = { + address, + bigNumber, + blockTag, + data, + hash, + number, + uint256, + + topics, + + filter: this.object({ + fromBlock: this.allowNull(blockTag, undefined), + toBlock: this.allowNull(blockTag, undefined), + blockHash: this.allowNull(hash, undefined), + address: this.allowNull(address, undefined), + topics: this.allowNull(topics, undefined) + }), + + transactionRequest: this.object({ + from: this.allowNull(address), + type: this.allowNull(number), + to: this.allowNull(address), + nonce: this.allowNull(number), + gasLimit: this.allowNull(uint256), + gasPrice: this.allowNull(uint256), + maxFeePerGas: this.allowNull(uint256), + maxPriorityFeePerGas: this.allowNull(uint256), + data: this.allowNull(data), + value: this.allowNull(uint256), + }), + + transactionResponse: this.object({ + hash: hash, + index: number, + + type: this.allowNull(number, 0), + + // These can be null for pending blocks + blockHash: this.allowNull(hash), + blockNumber: this.allowNull(number), + + // For Legacy transactions, this comes from the v + chainId: this.allowNull(number), + + from: address, + to: this.address, + + gasLimit: bigNumber, + + gasPrice: this.allowNull(bigNumber), + maxFeePerGas: this.allowNull(bigNumber), + maxPriorityFeePerGas: this.allowNull(bigNumber), + + value: bigNumber, + data: data, + nonce: number, + r: hash, + s: hash, + v: number, + accessList: this.allowNull(this.accessList) + }, { + index: [ "transactionIndex" ] + }), + }; + + this.#baseBlock = this.object({ + number: number, + hash: this.allowNull(hash, null), + timestamp: number, + + parentHash: hash, + + nonce: this.allowNull(stringify, "0x0000000000000000"), + difficulty: bigNumber, + + gasLimit: bigNumber, + gasUsed: bigNumber, + miner: this.allowNull(address, "0x0000000000000000000000000000000000000000"), + extraData: stringify, + + baseFeePerGas: this.allowNull(bigNumber), + }); + + } + + // An address + address(value: any): string { + return getAddress(value); + } + + // An address from a call result; may be zero-padded + callAddress(value: any): string { + if (dataLength(value) !== 32 || dataSlice(value, 0, 12) !== "0x000000000000000000000000") { + logger.throwArgumentError("invalid call address", "value", value); + } + return this.address(dataSlice(value, 12)); + } + + // An address from a transaction (e.g. { from: string, nonce: number }) + contractAddress(value: any): string { + return getCreateAddress({ + from: this.address(value.from), + nonce: logger.getNumber(value.nonce, "value.nonce") + }); + } + + // Block Tag + blockTag(value?: any): string { + if (value == null) { return "latest"; } + + switch (value) { + case "earliest": + return "0x0"; + case "latest": case "pending": case "safe": case "finalized": + return value; + } + + if (typeof(value) === "number" || (isHexString(value) && dataLength(value) < 32)) { + return toQuantity(value); + } + + return logger.throwArgumentError("invalid blockTag", "value", value); + } + + // Block objects + block(value: any, provider?: Provider): Block { + const params = this.#baseBlock(value); + params.transactions = value.transactions.map((t: any) => this.hash(t)); + return new Block(params, provider); + } + blockWithTransactions(value: any, provider?: Provider): Block { + throw new Error(); + } + + // Transactions + transactionRequest(value: any, provider?: Provider): PerformActionTransaction { + return this.#format.transactionRequest(value); + } + + transactionResponse(value: any, provider?: Provider): TransactionResponse { + value = Object.assign({ }, value); + + // @TODO: Use the remap feature + if (value.data == null && value.input != null) { value.data = value.input; } + if (value.gasLimit == null && value.gas) { value.gasLimit = value.gas; } + + value = this.#format.transactionResponse(value); + + const sig = Signature.from({ r: value.r, s: value.s, v: value.v }); + value.signature = sig; + if (value.chainId == null) { value.chainId = sig.legacyChainId; } + + return new TransactionResponse(value, provider); + } + + // Receipts + log(value: any, provider?: Provider): Log { + const log = this.object({ + address: this.address, + blockHash: this.hash, + blockNumber: this.number, + data: this.data, + index: this.number, + removed: this.boolean, + topics: this.topics, + transactionHash: this.hash, + transactionIndex: this.number, + }, { + index: [ "logIndex" ] + })(value); + return new Log(log, provider); + } + + receipt(value: any, provider?: Provider): TransactionReceipt { + const receipt = this.object({ + blockHash: this.hash, + blockNumber: this.number, + contractAddress: this.allowNull(this.address), + cumulativeGasUsed: this.bigNumber, + from: this.address, + gasUsed: this.bigNumber, + logs: this.arrayOf((v: any) => (this.log(v, provider))), + logsBloom: this.data, + root: this.allowNull(this.data), + status: this.allowNull(this.number), + to: this.address, + gasPrice: this.allowNull(this.bigNumber), + hash: this.hash, + index: this.number, + type: this.allowNull(this.number, 0), + }, { + hash: [ "transactionHash" ], + gasPrice: [ "effectiveGasPrice" ], + index: [ "transactionIndex" ] + })(value); + + // RSK incorrectly implemented EIP-658, so we munge things a bit here for it + if (receipt.root != null) { + if (receipt.root.length <= 4) { + // Could be 0x00, 0x0, 0x01 or 0x1 + const value = parseInt(receipt.root); + if (value === 0 || value === 1) { + // Make sure if both are specified, they match + if (receipt.status != null && receipt.status !== value) { + return logger.throwError("alt-root-status/status mismatch", "BAD_DATA", { + value: { root: receipt.root, status: receipt.status } + }); + } + receipt.status = value; + delete receipt.root; + } else { + return logger.throwError("invalid alt-root-status", "BAD_DATA", { + value: receipt.root + }); + } + } else if (!isHexString(receipt.root, 32)) { + // Must be a valid bytes32 + return logger.throwError("invalid receipt root hash", "BAD_DATA", { + value: receipt.root + }); + } + } + + //receipt.byzantium = (receipt.root == null); + + return new TransactionReceipt(receipt, provider); + } + + // Fitlers + topics(value: any): Array { + return this.#format.topics(value); + } + + filter(value: any): Filter { + return this.#format.filter(value); + } + + filterLog(value: any): any { + console.log("ME", value); + return null; + } + + // Converts a serialized transaction to a TransactionResponse + transaction(value: any): TransactionResponse { + throw new Error(); + } + + // Useful utility formatters functions, which if need be use the + // methods within the formatter to ensure internal compatibility + + // Access List; converts an AccessListish to an AccessList + accessList(value: any): AccessList { + return accessListify(value); + } + + // Converts falsish values to a specific value, otherwise use the formatter. Calls preserve `this`. + allowFalsish(format: FormatFunc, ifFalse: any): FormatFunc { + return ((value: any) => { + if (!value) { return ifFalse; } + return format.call(this, value); + }); + } + + // Allows null, optionally replacing it with a default value. Calls preserve `this`. + allowNull(format: FormatFunc, ifNull?: any): FormatFunc { + return ((value: any) => { + if (value == null) { return ifNull; } + return format.call(this, value); + }); + } + + // Requires an Array satisfying the formatter. Calls preserves `this`. + arrayOf(format: FormatFunc): FormatFunc { + return ((array: any) => { + if (!Array.isArray(array)) { throw new Error("not an array"); } + return array.map((i) => format.call(this, i)); + }); + } + + // Requires a value which is a value BigNumber + bigNumber(value: any): bigint { + return logger.getBigInt(value, "value"); + } + + uint256(value: any): bigint { + const result = this.bigNumber(value); + if (result < 0 || result > BN_MAX_UINT256) { + logger.throwArgumentError("invalid uint256", "value", value); + } + return result; + } + + // Requires a value which is a value boolean or string equivalent + boolean(value: any): boolean { + switch (value) { + case true: case "true": + return true; + case false: case "false": + return false; + } + return logger.throwArgumentError(`invalid boolean; ${ JSON.stringify(value) }`, "value", value); + } + + // Requires a value which is a valid hexstring. If dataOrLength is true, + // the length must be even (i.e. a datahexstring) or if it is a number, + // specifies teh number of bytes value must represent + _hexstring(dataOrLength?: boolean | number): FormatFunc { + if (dataOrLength == null) { dataOrLength = false; } + return (function(value: any) { + if (isHexString(value, dataOrLength)) { + return value.toLowerCase(); + } + throw new Error("bad hexstring"); + }); + } + + data(value: string): string { + if (dataLength(value) == null) { + logger.throwArgumentError("", "value", value); + } + return value; + } + + // Requires a network-native hash + hash(value: any): string { + if (dataLength(value) !== 32) { + logger.throwArgumentError("", "value", value); + } + return this.#format.data(value); + } + + // Requires a valid number, within the IEEE 754 safe range + number(value: any): number { + return logger.getNumber(value); + } + + // Requires an object which matches a fleet of other formatters + // Any FormatFunc may return `undefined` to have the value omitted + // from the result object. Calls preserve `this`. + object(format: Record, altNames?: Record>): FormatFunc { + return ((value: any) => { + const result: any = { }; + for (const key in format) { + let srcKey = key; + if (altNames && key in altNames && !(srcKey in value)) { + for (const altKey of altNames[key]) { + if (altKey in value) { + srcKey = altKey; + break; + } + } + } + + try { + const nv = format[key].call(this, value[srcKey]); + if (nv !== undefined) { result[key] = nv; } + } catch (error) { + const message = (error instanceof Error) ? error.message: "not-an-error"; + logger.throwError(`invalid value for value.${ key } (${ message })`, "BAD_DATA", { value }) + } + } + return result; + }); + } +} diff --git a/src.ts/providers/index.ts b/src.ts/providers/index.ts new file mode 100644 index 000000000..5fe28b49b --- /dev/null +++ b/src.ts/providers/index.ts @@ -0,0 +1,92 @@ +/* +export { + AbstractProvider, UnmanagedSubscriber +} from "./abstract-provider.js"; +*/ + +export { + AbstractSigner, + VoidSigner, + WrappedSigner +} from "./abstract-signer.js"; +/* +export { + showThrottleMessage +} from "./community.js"; + +export { getDefaultProvider } from "./default-provider.js"; + +export { EnsResolver } from "./ens-resolver.js"; +*/ + +export { Formatter } from "./formatter.js"; + +export { Network } from "./common-networks.js"; + +export { + NetworkPlugin, + GasCostPlugin, + EnsPlugin, + //LayerOneConnectionPlugin, + //MaxPriorityFeePlugin, + //PriceOraclePlugin, +} from "./plugins-network.js"; + +export { + Block, + FeeData, + Log, + TransactionReceipt, + TransactionResponse, + + dummyProvider, + + copyRequest, + //resolveTransactionRequest, +} from "./provider.js"; + +export { FallbackProvider } from "./provider-fallback.js"; +export { JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner } from "./provider-jsonrpc.js" + +export { AlchemyProvider } from "./provider-alchemy.js"; +export { AnkrProvider } from "./provider-ankr.js"; +export { CloudflareProvider } from "./provider-cloudflare.js"; +export { EtherscanProvider } from "./provider-etherscan.js"; +export { InfuraProvider } from "./provider-infura.js"; +//export { PocketProvider } from "./provider-pocket.js"; + +import { IpcSocketProvider } from "./provider-ipcsocket.js"; /*-browser*/ +export { IpcSocketProvider }; +export { SocketProvider } from "./provider-socket.js"; +export { WebSocketProvider } from "./provider-websocket.js"; + + +/* +export type { + ProviderPlugin, Subscriber, Subscription +} from "./abstract-provider.js" +*/ +export type { ContractRunner } from "./contracts.js"; +/* +export type { + CommunityResourcable +} from "./community.js"; + +export type { + AvatarLinkageType, AvatarLinkage, AvatarResult +} from "./ens-resolver.js"; +*/ +export type { FormatFunc } from "./formatter.js"; + +export type { Networkish } from "./network.js"; + +export type { GasCostParameters } from "./plugins-network.js"; + +export type { + BlockTag, + CallRequest, TransactionRequest, PreparedRequest, + EventFilter, Filter, FilterByBlockHash, OrphanFilter, ProviderEvent, TopicFilter, + Provider, +} from "./provider.js"; + +export type { Signer } from "./signer.js"; diff --git a/src.ts/providers/network.ts b/src.ts/providers/network.ts new file mode 100644 index 000000000..0075faabe --- /dev/null +++ b/src.ts/providers/network.ts @@ -0,0 +1,236 @@ +import { logger } from "../utils/logger.js"; +import { getStore, setStore } from "../utils/storage.js"; + +import { Formatter } from "./formatter.js"; +import { EnsPlugin, GasCostPlugin } from "./plugins-network.js"; + +import type { BigNumberish } from "../utils/index.js"; +import type { Freezable, Frozen } from "../utils/index.js"; +import type { TransactionLike } from "../transaction/index.js"; + +import type { NetworkPlugin } from "./plugins-network.js"; + +/** + * A Networkish can be used to allude to a Network, by specifing: + * - a [[Network]] object + * - a well-known (or registered) network name + * - a well-known (or registered) chain ID + * - an object with sufficient details to describe a network + */ +export type Networkish = Network | number | bigint | string | { + name?: string, + chainId?: number, + //layerOneConnection?: Provider, + ensAddress?: string, + ensNetwork?: number +}; + + + + +/* * * * +// Networks which operation against an L2 can use this plugin to +// specify how to access L1, for the purpose of resolving ENS, +// for example. +export class LayerOneConnectionPlugin extends NetworkPlugin { + readonly provider!: Provider; +// @TODO: Rename to ChainAccess and allow for connecting to any chain + constructor(provider: Provider) { + super("org.ethers.plugins.layer-one-connection"); + defineProperties(this, { provider }); + } + + clone(): LayerOneConnectionPlugin { + return new LayerOneConnectionPlugin(this.provider); + } +} +*/ + +/* * * * +export class PriceOraclePlugin extends NetworkPlugin { + readonly address!: string; + + constructor(address: string) { + super("org.ethers.plugins.price-oracle"); + defineProperties(this, { address }); + } + + clone(): PriceOraclePlugin { + return new PriceOraclePlugin(this.address); + } +} +*/ + +// Networks or clients with a higher need for security (such as clients +// that may automatically make CCIP requests without user interaction) +// can use this plugin to anonymize requests or intercept CCIP requests +// to notify and/or receive authorization from the user +/* * * * +export type FetchDataFunc = (req: Frozen) => Promise; +export class CcipPreflightPlugin extends NetworkPlugin { + readonly fetchData!: FetchDataFunc; + + constructor(fetchData: FetchDataFunc) { + super("org.ethers.plugins.ccip-preflight"); + defineProperties(this, { fetchData }); + } + + clone(): CcipPreflightPlugin { + return new CcipPreflightPlugin(this.fetchData); + } +} +*/ + +const Networks: Map Network> = new Map(); + +const defaultFormatter = new Formatter(); + +export class Network implements Freezable { + #props: { + name: string, + chainId: bigint, + + formatter: Formatter, + + plugins: Map + }; + + constructor(name: string, _chainId: BigNumberish, formatter?: Formatter) { + const chainId = logger.getBigInt(_chainId); + if (formatter == null) { formatter = defaultFormatter; } + const plugins = new Map(); + this.#props = { name, chainId, formatter, plugins }; + } + + toJSON(): any { + return { name: this.name, chainId: this.chainId }; + } + + get name(): string { return getStore(this.#props, "name"); } + set name(value: string) { setStore(this.#props, "name", value); } + + get chainId(): bigint { return getStore(this.#props, "chainId"); } + set chainId(value: BigNumberish) { setStore(this.#props, "chainId", logger.getBigInt(value, "chainId")); } + + get formatter(): Formatter { return getStore(this.#props, "formatter"); } + set formatter(value: Formatter) { setStore(this.#props, "formatter", value); } + + get plugins(): Array { + return Array.from(this.#props.plugins.values()); + } + + attachPlugin(plugin: NetworkPlugin): this { + if (this.isFrozen()) { throw new Error("frozen"); } + if (this.#props.plugins.get(plugin.name)) { + throw new Error(`cannot replace existing plugin: ${ plugin.name } `); + } + this.#props.plugins.set(plugin.name, plugin.validate(this)); + return this; + } + + getPlugin(name: string): null | T { + return (this.#props.plugins.get(name)) || null; + } + + // Gets a list of Plugins which match basename, ignoring any fragment + getPlugins(basename: string): Array { + return >(this.plugins.filter((p) => (p.name.split("#")[0] === basename))); + } + + clone(): Network { + const clone = new Network(this.name, this.chainId, this.formatter); + this.plugins.forEach((plugin) => { + clone.attachPlugin(plugin.clone()); + }); + return clone; + } + + freeze(): Frozen { + Object.freeze(this.#props); + return this; + } + + isFrozen(): boolean { + return Object.isFrozen(this.#props); + } + + computeIntrinsicGas(tx: TransactionLike): number { + const costs = this.getPlugin("org.ethers.gas-cost") || (new GasCostPlugin()); + + let gas = costs.txBase; + if (tx.to == null) { gas += costs.txCreate; } + if (tx.data) { + for (let i = 2; i < tx.data.length; i += 2) { + if (tx.data.substring(i, i + 2) === "00") { + gas += costs.txDataZero; + } else { + gas += costs.txDataNonzero; + } + } + } + + if (tx.accessList) { + const accessList = this.formatter.accessList(tx.accessList); + for (const addr in accessList) { + gas += costs.txAccessListAddress + costs.txAccessListStorageKey * accessList[addr].storageKeys.length; + } + } + + return gas; + } + + static from(network?: Networkish): Network { + // Default network + if (network == null) { return Network.from("homestead"); } + + // Canonical name or chain ID + if (typeof(network) === "number") { network = BigInt(network); } + if (typeof(network) === "string" || typeof(network) === "bigint") { + const networkFunc = Networks.get(network); + if (networkFunc) { return networkFunc(); } + if (typeof(network) === "bigint") { + return new Network("unknown", network); + } + + logger.throwArgumentError("unknown network", "network", network); + } + + // Clonable with network-like abilities + if (typeof((network).clone) === "function") { + const clone = (network).clone(); + //if (typeof(network.name) !== "string" || typeof(network.chainId) !== "number") { + //} + return clone; + } + + // Networkish + if (typeof(network) === "object") { + if (typeof(network.name) !== "string" || typeof(network.chainId) !== "number") { + logger.throwArgumentError("invalid network object name or chainId", "network", network); + } + + const custom = new Network((network.name), (network.chainId)); + + if ((network).ensAddress || (network).ensNetwork != null) { + custom.attachPlugin(new EnsPlugin((network).ensAddress, (network).ensNetwork)); + } + + //if ((network).layerOneConnection) { + // custom.attachPlugin(new LayerOneConnectionPlugin((network).layerOneConnection)); + //} + + return custom; + } + + return logger.throwArgumentError("invalid network", "network", network); + } + + static register(nameOrChainId: string | number | bigint, networkFunc: () => Network): void { + if (typeof(nameOrChainId) === "number") { nameOrChainId = BigInt(nameOrChainId); } + const existing = Networks.get(nameOrChainId); + if (existing) { + logger.throwArgumentError(`conflicting network for ${ JSON.stringify(existing.name) }`, "nameOrChainId", nameOrChainId); + } + Networks.set(nameOrChainId, networkFunc); + } +} diff --git a/src.ts/providers/pagination.ts b/src.ts/providers/pagination.ts new file mode 100644 index 000000000..022688c03 --- /dev/null +++ b/src.ts/providers/pagination.ts @@ -0,0 +1,8 @@ +export interface PaginationResult extends Array { + next(): Promise>; + + // The total number of results available or null if unknown + totalResults: null | number; + + done: boolean; +} diff --git a/src.ts/providers/plugins-network.ts b/src.ts/providers/plugins-network.ts new file mode 100644 index 000000000..493058796 --- /dev/null +++ b/src.ts/providers/plugins-network.ts @@ -0,0 +1,143 @@ +import { defineProperties } from "../utils/properties.js"; + +import { logger } from "../utils/logger.js"; + +//import { BigNumberish } from "../math/index.js"; + +import type { Network } from "./network.js"; +import type { FeeData, Provider } from "./provider.js"; + + +const EnsAddress = "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e"; + +export class NetworkPlugin { + readonly name!: string; + + constructor(name: string) { + defineProperties(this, { name }); + } + + clone(): NetworkPlugin { + return new NetworkPlugin(this.name); + } + + validate(network: Network): NetworkPlugin { + return this; + } +} + +// Networks can use this plugin to override calculations for the +// intrinsic gas cost of a transaction for networks that differ +// from the latest hardfork on Ethereum mainnet. +export type GasCostParameters = { + txBase?: number; + txCreate?: number; + txDataZero?: number; + txDataNonzero?: number; + txAccessListStorageKey?: number; + txAccessListAddress?: number; +}; + +export class GasCostPlugin extends NetworkPlugin { + readonly effectiveBlock!: number; + + readonly txBase!: number; + readonly txCreate!: number; + readonly txDataZero!: number; + readonly txDataNonzero!: number; + readonly txAccessListStorageKey!: number; + readonly txAccessListAddress!: number; + + constructor(effectiveBlock: number = 0, costs?: GasCostParameters) { + super(`org.ethers.network-plugins.gas-cost#${ (effectiveBlock || 0) }`); + + const props: Record = { effectiveBlock }; + function set(name: keyof GasCostParameters, nullish: number): void { + let value = (costs || { })[name]; + if (value == null) { value = nullish; } + if (typeof(value) !== "number") { + logger.throwArgumentError(`invalud value for ${ name }`, "costs", costs); + } + props[name] = value; + } + + set("txBase", 21000); + set("txCreate", 32000); + set("txDataZero", 4); + set("txDataNonzero", 16); + set("txAccessListStorageKey", 1900); + set("txAccessListAddress", 2400); + + defineProperties(this, props); + } + + clone(): GasCostPlugin { + return new GasCostPlugin(this.effectiveBlock, this); + } +} + +// Networks shoudl use this plugin to specify the contract address +// and network necessary to resolve ENS names. +export class EnsPlugin extends NetworkPlugin { + + // The ENS contract address + readonly address!: string; + + // The network ID that the ENS contract lives on + readonly targetNetwork!: number; + + constructor(address?: null | string, targetNetwork?: null | number) { + super("org.ethers.network-plugins.ens"); + defineProperties(this, { + address: (address || EnsAddress), + targetNetwork: ((targetNetwork == null) ? 1: targetNetwork) + }); + } + + clone(): EnsPlugin { + return new EnsPlugin(this.address, this.targetNetwork); + } + + validate(network: Network): this { + network.formatter.address(this.address); + return this; + } +} +/* +export class MaxPriorityFeePlugin extends NetworkPlugin { + readonly priorityFee!: bigint; + + constructor(priorityFee: BigNumberish) { + super("org.ethers.plugins.max-priority-fee"); + defineProperties(this, { + priorityFee: logger.getBigInt(priorityFee) + }); + } + + async getPriorityFee(provider: Provider): Promise { + return this.priorityFee; + } + + clone(): MaxPriorityFeePlugin { + return new MaxPriorityFeePlugin(this.priorityFee); + } +} +*/ +export class FeeDataNetworkPlugin extends NetworkPlugin { + readonly #feeDataFunc: (provider: Provider) => Promise; + + get feeDataFunc(): (provider: Provider) => Promise { return this.#feeDataFunc; } + + constructor(feeDataFunc: (provider: Provider) => Promise) { + super("org.ethers.network-plugins.fee-data"); + this.#feeDataFunc = feeDataFunc; + } + + async getFeeData(provider: Provider): Promise { + return await this.#feeDataFunc(provider); + } + + clone(): FeeDataNetworkPlugin { + return new FeeDataNetworkPlugin(this.#feeDataFunc); + } +} diff --git a/src.ts/providers/provider-alchemy.ts b/src.ts/providers/provider-alchemy.ts new file mode 100644 index 000000000..c10b2cefe --- /dev/null +++ b/src.ts/providers/provider-alchemy.ts @@ -0,0 +1,114 @@ + +import { defineProperties } from "../utils/properties.js"; +import { FetchRequest } from "../utils/fetch.js"; + +import { showThrottleMessage } from "./community.js"; +import { logger } from "../utils/logger.js"; +import { Network } from "./network.js"; +import { JsonRpcProvider } from "./provider-jsonrpc.js"; + +import type { AbstractProvider, PerformActionRequest } from "./abstract-provider.js"; +import type { CommunityResourcable } from "./community.js"; +import type { Networkish } from "./network.js"; + + +const defaultApiKey = "_gg7wSSi0KMBsdKnGVfHDueq6xMB9EkC" + +function getHost(name: string): string { + switch(name) { + case "homestead": + return "eth-mainnet.alchemyapi.io"; + case "ropsten": + return "eth-ropsten.alchemyapi.io"; + case "rinkeby": + return "eth-rinkeby.alchemyapi.io"; + case "goerli": + return "eth-goerli.alchemyapi.io"; + case "kovan": + return "eth-kovan.alchemyapi.io"; + case "matic": + return "polygon-mainnet.g.alchemy.com"; + case "maticmum": + return "polygon-mumbai.g.alchemy.com"; + case "arbitrum": + return "arb-mainnet.g.alchemy.com"; + case "arbitrum-rinkeby": + return "arb-rinkeby.g.alchemy.com"; + case "optimism": + return "opt-mainnet.g.alchemy.com"; + case "optimism-kovan": + return "opt-kovan.g.alchemy.com"; + } + + return logger.throwArgumentError("unsupported network", "network", name); +} + +export class AlchemyProvider extends JsonRpcProvider implements CommunityResourcable { + readonly apiKey!: string; + + constructor(_network: Networkish = "homestead", apiKey?: null | string) { + const network = Network.from(_network); + if (apiKey == null) { apiKey = defaultApiKey; } + + const request = AlchemyProvider.getRequest(network, apiKey); + super(request, network, { staticNetwork: network }); + + defineProperties(this, { apiKey }); + } + + _getProvider(chainId: number): AbstractProvider { + try { + return new AlchemyProvider(chainId, this.apiKey); + } catch (error) { } + return super._getProvider(chainId); + } + + async _perform(req: PerformActionRequest): Promise { + + // https://docs.alchemy.com/reference/trace-transaction + if (req.method === "getTransactionResult") { + const trace = await this.send("trace_transaction", [ req.hash ]); + if (trace == null) { return null; } + + let data: undefined | string; + let error = false; + try { + data = trace[0].result.output; + error = (trace[0].error === "Reverted"); + } catch (error) { } + + if (data) { + if (error) { + logger.throwError("an error occurred during transaction executions", "CALL_EXCEPTION", { + data + }); + } + return data; + } + + return logger.throwError("could not parse trace result", "BAD_DATA", { value: trace }); + } + + return await super._perform(req); + } + + isCommunityResource(): boolean { + return (this.apiKey === defaultApiKey); + } + + static getRequest(network: Network, apiKey?: string): FetchRequest { + if (apiKey == null) { apiKey = defaultApiKey; } + + const request = new FetchRequest(`https:/\/${ getHost(network.name) }/v2/${ apiKey }`); + request.allowGzip = true; + + if (apiKey === defaultApiKey) { + request.retryFunc = async (request, response, attempt) => { + showThrottleMessage("alchemy"); + return true; + } + } + + return request; + } +} diff --git a/src.ts/providers/provider-ankr.ts b/src.ts/providers/provider-ankr.ts new file mode 100644 index 000000000..b51db0ac9 --- /dev/null +++ b/src.ts/providers/provider-ankr.ts @@ -0,0 +1,77 @@ +import { defineProperties } from "../utils/properties.js"; +import { FetchRequest } from "../utils/fetch.js"; + +import { AbstractProvider } from "./abstract-provider.js"; +import { showThrottleMessage } from "./community.js"; +import { logger } from "../utils/logger.js"; +import { Network } from "./network.js"; +import { JsonRpcProvider } from "./provider-jsonrpc.js"; + +import type { CommunityResourcable } from "./community.js"; +import type { Networkish } from "./network.js"; + + +const defaultApiKey = "9f7d929b018cdffb338517efa06f58359e86ff1ffd350bc889738523659e7972"; + +function getHost(name: string): string { + switch (name) { + case "homestead": + return "rpc.ankr.com/eth"; + case "ropsten": + return "rpc.ankr.com/eth_ropsten"; + case "rinkeby": + return "rpc.ankr.com/eth_rinkeby"; + case "goerli": + return "rpc.ankr.com/eth_goerli"; + case "matic": + return "rpc.ankr.com/polygon"; + case "arbitrum": + return "rpc.ankr.com/arbitrum"; + } + return logger.throwArgumentError("unsupported network", "network", name); +} + + +export class AnkrProvider extends JsonRpcProvider implements CommunityResourcable { + readonly apiKey!: string; + + constructor(_network: Networkish = "homestead", apiKey?: null | string) { + const network = Network.from(_network); + if (apiKey == null) { apiKey = defaultApiKey; } + + // Ankr does not support filterId, so we force polling + const options = { polling: true, staticNetwork: network }; + + const request = AnkrProvider.getRequest(network, apiKey); + super(request, network, options); + + defineProperties(this, { apiKey }); + } + + _getProvider(chainId: number): AbstractProvider { + try { + return new AnkrProvider(chainId, this.apiKey); + } catch (error) { } + return super._getProvider(chainId); + } + + static getRequest(network: Network, apiKey?: null | string): FetchRequest { + if (apiKey == null) { apiKey = defaultApiKey; } + + const request = new FetchRequest(`https:/\/${ getHost(network.name) }/${ apiKey }`); + request.allowGzip = true; + + if (apiKey === defaultApiKey) { + request.retryFunc = async (request, response, attempt) => { + showThrottleMessage("AnkrProvider"); + return true; + }; + } + + return request; + } + + isCommunityResource(): boolean { + return (this.apiKey === defaultApiKey); + } +} diff --git a/src.ts/providers/provider-cloudflare.ts b/src.ts/providers/provider-cloudflare.ts new file mode 100644 index 000000000..efdb008a5 --- /dev/null +++ b/src.ts/providers/provider-cloudflare.ts @@ -0,0 +1,17 @@ + +import { logger } from "../utils/logger.js"; +import { Network } from "./network.js"; +import { JsonRpcProvider } from "./provider-jsonrpc.js"; + +import type { Networkish } from "./network.js"; + + +export class CloudflareProvider extends JsonRpcProvider { + constructor(_network: Networkish = "homestead") { + const network = Network.from(_network); + if (network.name !== "homestead") { + return logger.throwArgumentError("unsupported network", "network", _network); + } + super("https:/\/cloudflare-eth.com/", network, { staticNetwork: network }); + } +} diff --git a/src.ts/providers/provider-etherscan.ts b/src.ts/providers/provider-etherscan.ts new file mode 100644 index 000000000..32ece799f --- /dev/null +++ b/src.ts/providers/provider-etherscan.ts @@ -0,0 +1,508 @@ +import { hexlify, isHexString } from "../utils/data.js"; +import { toQuantity } from "../utils/maths.js"; +import { isError } from "../utils/errors.js"; +import { defineProperties } from "../utils/properties.js"; +import { toUtf8String } from "../utils/utf8.js"; +import { FetchRequest } from "../utils/fetch.js"; + +if (false) { console.log(isHexString, isError); } // @TODO + +import { AbstractProvider } from "./abstract-provider.js"; +import { Network } from "./network.js"; +import { NetworkPlugin } from "./plugins-network.js"; + + +import { PerformActionRequest } from "./abstract-provider.js"; +import type { Networkish } from "./network.js"; +//import type { } from "./pagination"; +import type { TransactionRequest } from "./provider.js"; + +import { logger } from "../utils/logger.js"; + + +const defaultApiKey = "9D13ZE7XSBTJ94N9BNJ2MA33VMAY2YPIRB"; + + + +const EtherscanPluginId = "org.ethers.plugins.etherscan"; +export class EtherscanPlugin extends NetworkPlugin { + readonly baseUrl!: string; + readonly communityApiKey!: string; + + constructor(baseUrl: string, communityApiKey: string) { + super(EtherscanPluginId); + //if (communityApiKey == null) { communityApiKey = null; } + defineProperties(this, { baseUrl, communityApiKey }); + } + + clone(): EtherscanPlugin { + return new EtherscanPlugin(this.baseUrl, this.communityApiKey); + } +} + + +export class EtherscanProvider extends AbstractProvider { + readonly network!: Network; + readonly apiKey!: string; + + constructor(_network?: Networkish, apiKey?: string) { + super(); + + const network = Network.from(_network); + if (apiKey == null) { + const plugin = network.getPlugin(EtherscanPluginId); + if (plugin) { + apiKey = plugin.communityApiKey; + } else { + apiKey = defaultApiKey; + } + } + + defineProperties(this, { apiKey, network }); + + // Test that the network is supported by Etherscan + this.getBaseUrl(); + } + + getBaseUrl(): string { + const plugin = this.network.getPlugin(EtherscanPluginId); + if (plugin) { return plugin.baseUrl; } + + switch(this.network.name) { + case "homestead": + return "https:/\/api.etherscan.io"; + case "ropsten": + return "https:/\/api-ropsten.etherscan.io"; + case "rinkeby": + return "https:/\/api-rinkeby.etherscan.io"; + case "kovan": + return "https:/\/api-kovan.etherscan.io"; + case "goerli": + return "https:/\/api-goerli.etherscan.io"; + default: + } + + return logger.throwArgumentError("unsupported network", "network", this.network); + } + + getUrl(module: string, params: Record): string { + const query = Object.keys(params).reduce((accum, key) => { + const value = params[key]; + if (value != null) { + accum += `&${ key }=${ value }` + } + return accum + }, ""); + const apiKey = ((this.apiKey) ? `&apikey=${ this.apiKey }`: ""); + return `${ this.getBaseUrl() }/api?module=${ module }${ query }${ apiKey }`; + } + + getPostUrl(): string { + return `${ this.getBaseUrl() }/api`; + } + + getPostData(module: string, params: Record): Record { + params.module = module; + params.apikey = this.apiKey; + return params; + } + + async detectNetwork(): Promise { + return this.network; + } + + async fetch(module: string, params: Record, post?: boolean): Promise { + const url = (post ? this.getPostUrl(): this.getUrl(module, params)); + const payload = (post ? this.getPostData(module, params): null); + + /* + this.emit("debug", { + action: "request", + request: url, + provider: this + }); + */ + const request = new FetchRequest(url); + request.processFunc = async (request, response) => { + const result = response.hasBody() ? JSON.parse(toUtf8String(response.body)): { }; + const throttle = ((typeof(result.result) === "string") ? result.result: "").toLowerCase().indexOf("rate limit") >= 0; + if (module === "proxy") { + // This JSON response indicates we are being throttled + if (result && result.status == 0 && result.message == "NOTOK" && throttle) { + response.throwThrottleError(result.result); + } + } else { + if (throttle) { + response.throwThrottleError(result.result); + } + } + return response; + }; + // @TODO: + //throttleSlotInterval: 1000, + + if (payload) { + request.setHeader("content-type", "application/x-www-form-urlencoded; charset=UTF-8"); + request.body = Object.keys(payload).map((k) => `${ k }=${ payload[k] }`).join("&"); + } + + const response = await request.send(); + response.assertOk(); + + if (!response.hasBody()) { + throw new Error(); + } + + /* + this.emit("debug", { + action: "response", + request: url, + response: deepCopy(result), + provider: this + }); + */ + + const result = JSON.parse(toUtf8String(response.body)); + + if (module === "proxy") { + if (result.jsonrpc != "2.0") { + // @TODO: not any + const error: any = new Error("invalid response"); + error.result = JSON.stringify(result); + throw error; + } + + if (result.error) { + // @TODO: not any + const error: any = new Error(result.error.message || "unknown error"); + if (result.error.code) { error.code = result.error.code; } + if (result.error.data) { error.data = result.error.data; } + throw error; + } + + return result.result; + + } else { + // getLogs, getHistory have weird success responses + if (result.status == 0 && (result.message === "No records found" || result.message === "No transactions found")) { + return result.result; + } + + if (result.status != 1 || result.message != "OK") { + const error: any = new Error("invalid response"); + error.result = JSON.stringify(result); + // if ((result.result || "").toLowerCase().indexOf("rate limit") >= 0) { + // error.throttleRetry = true; + // } + throw error; + } + + return result.result; + } + } + + // The transaction has already been sanitized by the calls in Provider + _getTransactionPostData(transaction: TransactionRequest): Record { + const result: Record = { }; + for (let key in transaction) { + if ((transaction)[key] == null) { continue; } + let value = (transaction)[key]; + if (key === "type" && value === 0) { continue; } + + // Quantity-types require no leading zero, unless 0 + if (({ type: true, gasLimit: true, gasPrice: true, maxFeePerGs: true, maxPriorityFeePerGas: true, nonce: true, value: true })[key]) { + value = toQuantity(hexlify(value)); + } else if (key === "accessList") { + value = "[" + this.network.formatter.accessList(value).map((set) => { + return `{address:"${ set.address }",storageKeys:["${ set.storageKeys.join('","') }"]}`; + }).join(",") + "]"; + } else { + value = hexlify(value); + } + result[key] = value; + } + return result; + } + + + _checkError(req: PerformActionRequest, error: Error, transaction: any): never { + /* + let body = ""; + if (isError(error, Logger.Errors.SERVER_ERROR) && error.response && error.response.hasBody()) { + body = toUtf8String(error.response.body); + } + console.log(body); + + // Undo the "convenience" some nodes are attempting to prevent backwards + // incompatibility; maybe for v6 consider forwarding reverts as errors + if (method === "call" && body) { + + // Etherscan keeps changing their string + if (body.match(/reverted/i) || body.match(/VM execution error/i)) { + + // Etherscan prefixes the data like "Reverted 0x1234" + let data = e.data; + if (data) { data = "0x" + data.replace(/^.*0x/i, ""); } + if (!isHexString(data)) { data = "0x"; } + + logger.throwError("call exception", Logger.Errors.CALL_EXCEPTION, { + error, data + }); + } + } + + // Get the message from any nested error structure + let message = error.message; + if (isError(error, Logger.Errors.SERVER_ERROR)) { + if (error.error && typeof(error.error.message) === "string") { + message = error.error.message; + } else if (typeof(error.body) === "string") { + message = error.body; + } else if (typeof(error.responseText) === "string") { + message = error.responseText; + } + } + message = (message || "").toLowerCase(); + + // "Insufficient funds. The account you tried to send transaction from + // does not have enough funds. Required 21464000000000 and got: 0" + if (message.match(/insufficient funds/)) { + logger.throwError("insufficient funds for intrinsic transaction cost", Logger.Errors.INSUFFICIENT_FUNDS, { + error, transaction, info: { method } + }); + } + + // "Transaction with the same hash was already imported." + if (message.match(/same hash was already imported|transaction nonce is too low|nonce too low/)) { + logger.throwError("nonce has already been used", Logger.Errors.NONCE_EXPIRED, { + error, transaction, info: { method } + }); + } + + // "Transaction gas price is too low. There is another transaction with + // same nonce in the queue. Try increasing the gas price or incrementing the nonce." + if (message.match(/another transaction with same nonce/)) { + logger.throwError("replacement fee too low", Logger.Errors.REPLACEMENT_UNDERPRICED, { + error, transaction, info: { method } + }); + } + + if (message.match(/execution failed due to an exception|execution reverted/)) { + logger.throwError("cannot estimate gas; transaction may fail or may require manual gas limit", Logger.Errors.UNPREDICTABLE_GAS_LIMIT, { + error, transaction, info: { method } + }); + } +*/ + throw error; + } + + async _detectNetwork(): Promise { + return this.network; + } + + async _perform(req: PerformActionRequest): Promise { + switch (req.method) { + case "chainId": + return this.network.chainId; + + case "getBlockNumber": + return this.fetch("proxy", { action: "eth_blockNumber" }); + + case "getGasPrice": + return this.fetch("proxy", { action: "eth_gasPrice" }); + + case "getBalance": + // Returns base-10 result + return this.fetch("account", { + action: "balance", + address: req.address, + tag: req.blockTag + }); + + case "getTransactionCount": + return this.fetch("proxy", { + action: "eth_getTransactionCount", + address: req.address, + tag: req.blockTag + }); + + case "getCode": + return this.fetch("proxy", { + action: "eth_getCode", + address: req.address, + tag: req.blockTag + }); + + case "getStorageAt": + return this.fetch("proxy", { + action: "eth_getStorageAt", + address: req.address, + position: req.position, + tag: req.blockTag + }); + + case "broadcastTransaction": + return this.fetch("proxy", { + action: "eth_sendRawTransaction", + hex: req.signedTransaction + }, true).catch((error) => { + return this._checkError(req, error, req.signedTransaction); + }); + + case "getBlock": + if ("blockTag" in req) { + return this.fetch("proxy", { + action: "eth_getBlockByNumber", + tag: req.blockTag, + boolean: (req.includeTransactions ? "true": "false") + }); + } + + return logger.throwError("getBlock by blockHash not supported by Etherscan", "UNSUPPORTED_OPERATION", { + operation: "getBlock(blockHash)" + }); + + case "getTransaction": + return this.fetch("proxy", { + action: "eth_getTransactionByHash", + txhash: req.hash + }); + + case "getTransactionReceipt": + return this.fetch("proxy", { + action: "eth_getTransactionReceipt", + txhash: req.hash + }); + + case "call": { + if (req.blockTag !== "latest") { + throw new Error("EtherscanProvider does not support blockTag for call"); + } + + const postData = this._getTransactionPostData(req.transaction); + postData.module = "proxy"; + postData.action = "eth_call"; + + try { + return await this.fetch("proxy", postData, true); + } catch (error) { + return this._checkError(req, error, req.transaction); + } + } + + case "estimateGas": { + const postData = this._getTransactionPostData(req.transaction); + postData.module = "proxy"; + postData.action = "eth_estimateGas"; + + try { + return await this.fetch("proxy", postData, true); + } catch (error) { + return this._checkError(req, error, req.transaction); + } + } +/* + case "getLogs": { + // Needs to complain if more than one address is passed in + const args: Record = { action: "getLogs" } + + if (params.filter.fromBlock) { + args.fromBlock = checkLogTag(params.filter.fromBlock); + } + + if (params.filter.toBlock) { + args.toBlock = checkLogTag(params.filter.toBlock); + } + + if (params.filter.address) { + args.address = params.filter.address; + } + + // @TODO: We can handle slightly more complicated logs using the logs API + if (params.filter.topics && params.filter.topics.length > 0) { + if (params.filter.topics.length > 1) { + logger.throwError("unsupported topic count", Logger.Errors.UNSUPPORTED_OPERATION, { topics: params.filter.topics }); + } + if (params.filter.topics.length === 1) { + const topic0 = params.filter.topics[0]; + if (typeof(topic0) !== "string" || topic0.length !== 66) { + logger.throwError("unsupported topic format", Logger.Errors.UNSUPPORTED_OPERATION, { topic0: topic0 }); + } + args.topic0 = topic0; + } + } + + const logs: Array = await this.fetch("logs", args); + + // Cache txHash => blockHash + let blocks: { [tag: string]: string } = {}; + + // Add any missing blockHash to the logs + for (let i = 0; i < logs.length; i++) { + const log = logs[i]; + if (log.blockHash != null) { continue; } + if (blocks[log.blockNumber] == null) { + const block = await this.getBlock(log.blockNumber); + if (block) { + blocks[log.blockNumber] = block.hash; + } + } + + log.blockHash = blocks[log.blockNumber]; + } + + return logs; + } +*/ + default: + break; + } + + return super._perform(req); + } + + async getNetwork(): Promise { + return this.network; + } + + async getEtherPrice(): Promise { + if (this.network.name !== "homestead") { return 0.0; } + return parseFloat((await this.fetch("stats", { action: "ethprice" })).ethusd); + } + + isCommunityResource(): boolean { + const plugin = this.network.getPlugin(EtherscanPluginId); + if (plugin) { return (plugin.communityApiKey === this.apiKey); } + + return (defaultApiKey === this.apiKey); + } +} +/* +(async function() { + const provider = new EtherscanProvider(); + console.log(provider); + console.log(await provider.getBlockNumber()); + / * + provider.on("block", (b) => { + console.log("BB", b); + }); + console.log(await provider.getTransactionReceipt("0xa5ded92f548e9f362192f9ab7e5b3fbc9b5a919a868e29247f177d49ce38de6e")); + + provider.once("0xa5ded92f548e9f362192f9ab7e5b3fbc9b5a919a868e29247f177d49ce38de6e", (tx) => { + console.log("TT", tx); + }); + * / + try { + console.log(await provider.getBlock(100)); + } catch (error) { + console.log(error); + } + + try { + console.log(await provider.getBlock(13821768)); + } catch (error) { + console.log(error); + } + +})(); +*/ diff --git a/src.ts/providers/provider-fallback.ts b/src.ts/providers/provider-fallback.ts new file mode 100644 index 000000000..c2ca31e64 --- /dev/null +++ b/src.ts/providers/provider-fallback.ts @@ -0,0 +1,555 @@ + +import { hexlify } from "../utils/data.js"; + +import { AbstractProvider } from "./abstract-provider.js"; +import { logger } from "../utils/logger.js"; +import { Network } from "./network.js" + +import type { Frozen } from "../utils/index.js"; + +import type { PerformActionRequest } from "./abstract-provider.js"; +import type { Networkish } from "./network.js" + +//const BN_0 = BigInt("0"); +const BN_1 = BigInt("1"); +const BN_2 = BigInt("2"); + +function shuffle(array: Array): void { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const tmp = array[i]; + array[i] = array[j]; + array[j] = tmp; + } +} + +function stall(duration: number) { + return new Promise((resolve) => { setTimeout(resolve, duration); }); +} + +function getTime(): number { return (new Date()).getTime(); } + +export interface FallbackProviderConfig { + + // The provider + provider: AbstractProvider; + + // How long to wait for a response before getting impatient + // and ispatching the next provider + stallTimeout?: number; + + // Lower values are dispatched first + priority?: number; + + // How much this provider contributes to the quorum + weight?: number; +}; + +const defaultConfig = { stallTimeout: 400, priority: 1, weight: 1 }; + +// We track a bunch of extra stuff that might help debug problems or +// optimize infrastructure later on. +export interface FallbackProviderState extends Required { + + // The most recent blockNumber this provider has reported (-2 if none) + blockNumber: number; + + // The number of total requests ever sent to this provider + requests: number; + + // The number of responses that errored + errorResponses: number; + + // The number of responses that occured after the result resolved + lateResponses: number; + + // How many times syncing was required to catch up the expected block + outOfSync: number; + + // The number of requests which reported unsupported operation + unsupportedEvents: number; + + // A rolling average (5% current duration) for response time + rollingDuration: number; + + // The ratio of quorum-agreed results to total + score: number; +} + +interface Config extends FallbackProviderState { + _updateNumber: null | Promise; + _network: null | Frozen; + _totalTime: number; +} + +const defaultState = { + blockNumber: -2, requests: 0, lateResponses: 0, errorResponses: 0, + outOfSync: -1, unsupportedEvents: 0, rollingDuration: 0, score: 0, + _network: null, _updateNumber: null, _totalTime: 0 +}; + + +async function waitForSync(config: Config, blockNumber: number): Promise { + while (config.blockNumber < 0 || config.blockNumber < blockNumber) { + if (!config._updateNumber) { + config._updateNumber = (async () => { + const blockNumber = await config.provider.getBlockNumber(); + if (blockNumber > config.blockNumber) { + config.blockNumber = blockNumber; + } + config._updateNumber = null; + })(); + } + await config._updateNumber; + config.outOfSync++; + } +} + +export type FallbackProviderOptions = { + // How many providers must agree on a value before reporting + // back the response + quorum: number; + + // How many providers must have reported the same event + // for it to be emitted + eventQuorum: number; + + // How many providers to dispatch each event to simultaneously. + // Set this to 0 to use getLog polling, which implies eventQuorum + // is equal to quorum. + eventWorkers: number; +}; + +type RunningState = { + config: Config; + staller: null | Promise; + didBump: boolean; + perform: null | Promise; + done: boolean; + result: { result: any } | { error: Error } +} + +// Normalizes a result to a string that can be used to compare against +// other results using normal string equality +function normalize(network: Frozen, value: any, req: PerformActionRequest): string { + switch (req.method) { + case "chainId": + return logger.getBigInt(value).toString(); + case "getBlockNumber": + return logger.getNumber(value).toString(); + case "getGasPrice": + return logger.getBigInt(value).toString(); + case "getBalance": + return logger.getBigInt(value).toString(); + case "getTransactionCount": + return logger.getNumber(value).toString(); + case "getCode": + return hexlify(value); + case "getStorageAt": + return hexlify(value); + case "getBlock": + if (req.includeTransactions) { + return JSON.stringify(network.formatter.blockWithTransactions(value)); + } + return JSON.stringify(network.formatter.block(value)); + case "getTransaction": + return JSON.stringify(network.formatter.transactionResponse(value)); + case "getTransactionReceipt": + return JSON.stringify(network.formatter.receipt(value)); + case "call": + return hexlify(value); + case "estimateGas": + return logger.getBigInt(value).toString(); + case "getLogs": + return JSON.stringify(value.map((v: any) => network.formatter.log(v))); + } + + return logger.throwError("unsupported method", "UNSUPPORTED_OPERATION", { + operation: `_perform(${ JSON.stringify(req.method) })` + }); +} + +type TallyResult = { + result: any; + normal: string; + weight: number; +}; + +// This strategy picks the highest wieght result, as long as the weight is +// equal to or greater than quorum +function checkQuorum(quorum: number, results: Array): any { + const tally: Map = new Map(); + for (const { result, normal, weight } of results) { + const t = tally.get(normal) || { result, weight: 0 }; + t.weight += weight; + tally.set(normal, t); + } + + let bestWeight = 0; + let bestResult = undefined; + + for (const { weight, result } of tally.values()) { + if (weight >= quorum && weight > bestWeight) { + bestWeight = weight; + bestResult = result; + } + } + + return bestResult; +} + +/* +function getMean(results: Array): bigint { + const total = results.reduce((a, r) => (a + BigInt(r.result)), BN_0); + return total / BigInt(results.length); +} +*/ + +function getMedian(results: Array): bigint { + // Get the sorted values + const values = results.map((r) => BigInt(r.result)); + values.sort((a, b) => ((a < b) ? -1: (b > a) ? 1: 0)); + + const mid = values.length / 2; + + // Odd-length; take the middle value + if (values.length % 2) { return values[mid]; } + + // Even length; take the ceiling of the mean of the center two values + return (values[mid - 1] + values[mid] + BN_1) / BN_2; +} + +function getFuzzyMode(quorum: number, results: Array): undefined | number { + if (quorum === 1) { return logger.getNumber(getMedian(results), "%internal"); } + + const tally: Map = new Map(); + const add = (result: number, weight: number) => { + const t = tally.get(result) || { result, weight: 0 }; + t.weight += weight; + tally.set(result, t); + }; + + for (const { weight, result } of results) { + const r = logger.getNumber(result); + add(r - 1, weight); + add(r, weight); + add(r + 1, weight); + } + + let bestWeight = 0; + let bestResult = undefined; + + for (const { weight, result } of tally.values()) { + // Use this result, if this result meets quorum and has either: + // - a better weight + // - or equal weight, but the result is larger + if (weight >= quorum && (weight > bestWeight || (bestResult != null && weight === bestWeight && result > bestResult))) { + bestWeight = weight; + bestResult = result; + } + } + + return bestResult; +} + +export class FallbackProvider extends AbstractProvider { + //readonly providerConfigs!: ReadonlyArray>>; + + readonly quorum: number; + readonly eventQuorum: number; + readonly eventWorkers: number; + + readonly #configs: Array; + + #height: number; + #initialSyncPromise: null | Promise; + + constructor(providers: Array, network?: Networkish) { + super(network); + this.#configs = providers.map((p) => { + if (p instanceof AbstractProvider) { + return Object.assign({ provider: p }, defaultConfig, defaultState ); + } else { + return Object.assign({ }, defaultConfig, p, defaultState ); + } + }); + + this.#height = -2; + this.#initialSyncPromise = null; + + this.quorum = 2; //Math.ceil(providers.length / 2); + this.eventQuorum = 1; + this.eventWorkers = 1; + + if (this.quorum > this.#configs.reduce((a, c) => (a + c.weight), 0)) { + logger.throwArgumentError("quorum exceed provider wieght", "quorum", this.quorum); + } + } + + // @TOOD: Copy these and only return public values + get providerConfigs(): Array { + return this.#configs.slice(); + } + + async _detectNetwork() { + return Network.from(logger.getBigInt(await this._perform({ method: "chainId" }))).freeze(); + } + + // @TODO: Add support to select providers to be the event subscriber + //_getSubscriber(sub: Subscription): Subscriber { + // throw new Error("@TODO"); + //} + + // Grab the next (random) config that is not already part of configs + #getNextConfig(configs: Array): null | Config { + // Shuffle the states, sorted by priority + const allConfigs = this.#configs.slice(); + shuffle(allConfigs); + allConfigs.sort((a, b) => (b.priority - a.priority)); + + for (const config of allConfigs) { + if (configs.indexOf(config) === -1) { return config; } + } + + return null; + } + + // Adds a new runner (if available) to running. + #addRunner(running: Set, req: PerformActionRequest): null | RunningState { + const config = this.#getNextConfig(Array.from(running).map((r) => r.config)); + if (config == null) { + return null; + } + + const result: any = { }; + + const runner: RunningState = { + config, result, didBump: false, done: false, + perform: null, staller: null + }; + + const now = getTime(); + + runner.perform = (async () => { + try { + config.requests++; + result.result = await config.provider._perform(req); + } catch (error) { + config.errorResponses++; + result.error = error; + } + + if (runner.done) { config.lateResponses++; } + + const dt = (getTime() - now); + config._totalTime += dt; + + config.rollingDuration = 0.95 * config.rollingDuration + 0.05 * dt; + + runner.perform = null; + })(); + + runner.staller = (async () => { + await stall(config.stallTimeout); + runner.staller = null; + })(); + + running.add(runner); + return runner; + } + + // Initializes the blockNumber and network for each runner and + // blocks until initialized + async #initialSync(): Promise { + let initialSync = this.#initialSyncPromise; + if (!initialSync) { + const promises: Array> = [ ]; + this.#configs.forEach((config) => { + promises.push(waitForSync(config, 0)); + promises.push((async () => { + config._network = await config.provider.getNetwork(); + })()); + }); + + this.#initialSyncPromise = initialSync = (async () => { + // Wait for all providers to have a block number and network + await Promise.all(promises); + + // Check all the networks match + let chainId: null | bigint = null; + for (const config of this.#configs) { + const network = >(config._network); + if (chainId == null) { + chainId = network.chainId; + } else if (network.chainId !== chainId) { + logger.throwError("cannot mix providers on different networks", "UNSUPPORTED_OPERATION", { + operation: "new FallbackProvider" + }); + } + } + })(); + } + + await initialSync + } + + + async #checkQuorum(running: Set, req: PerformActionRequest): Promise { + // Get all the result objects + const results: Array = [ ]; + for (const runner of running) { + if ("result" in runner.result) { + const result = runner.result.result; + results.push({ + result, + normal: normalize(>(runner.config._network), result, req), + weight: runner.config.weight + }); + } + } + + // Are there enough results to event meet quorum? + if (results.reduce((a, r) => (a + r.weight), 0) < this.quorum) { + return undefined; + } + + switch (req.method) { + case "getBlockNumber": { + // We need to get the bootstrap block height + if (this.#height === -2) { + const height = Math.ceil(logger.getNumber(getMedian(this.#configs.map((c) => ({ + result: c.blockNumber, + normal: logger.getNumber(c.blockNumber).toString(), + weight: c.weight + }))), "%internal")); + this.#height = height; + } + + const mode = getFuzzyMode(this.quorum, results); + if (mode === undefined) { return undefined; } + if (mode > this.#height) { this.#height = mode; } + return this.#height; + } + + case "getGasPrice": + case "estimateGas": + return getMedian(results); + + case "getBlock": + // Pending blocks are mempool dependant and already + // quite untrustworthy + if ("blockTag" in req && req.blockTag === "pending") { + return results[0].result; + } + return checkQuorum(this.quorum, results); + + case "chainId": + case "getBalance": + case "getTransactionCount": + case "getCode": + case "getStorageAt": + case "getTransaction": + case "getTransactionReceipt": + case "getLogs": + return checkQuorum(this.quorum, results); + + case "call": + // @TODO: Check errors + return checkQuorum(this.quorum, results); + + case "broadcastTransaction": + throw new Error("TODO"); + } + + return logger.throwError("unsupported method", "UNSUPPORTED_OPERATION", { + operation: `_perform(${ JSON.stringify((req).method) })` + }); + } + + async #waitForQuorum(running: Set, req: PerformActionRequest): Promise { + if (running.size === 0) { throw new Error("no runners?!"); } + + // Any promises that are interesting to watch for; an expired stall + // or a successful perform + const interesting: Array> = [ ]; + + //const results: Array = [ ]; + //const errors: Array = [ ]; + let newRunners = 0; + for (const runner of running) { +// @TODO: use runner.perfom != null + /* + if ("result" in runner.result) { + results.push(runner.result.result); + } else if ("error" in runner.result) { + errors.push(runner.result.error); + } +*/ + // No responses, yet; keep an eye on it + if (runner.perform) { + interesting.push(runner.perform); + } + + // Still stalling... + if (runner.staller) { + interesting.push(runner.staller); + continue; + } + + // This runner has already triggered another runner + if (runner.didBump) { continue; } + + // Got a response (result or error) or stalled; kick off another runner + runner.didBump = true; + newRunners++; + } + + // Check for quorum + /* + console.log({ results, errors } ); + if (results.length >= this.quorum) { + return results[0]; + } + + if (errors.length >= this.quorum) { + return errors[0]; + } + */ + const value = await this.#checkQuorum(running, req); + if (value !== undefined) { + if (value instanceof Error) { throw value; } + return value; + } + + // Add any new runners, because a staller timed out or a result + // or error response came in. + for (let i = 0; i < newRunners; i++) { + this.#addRunner(running, req) + } + + if (interesting.length === 0) { + throw new Error("quorum not met"); +// return logger.throwError("failed to meet quorum", "", { +// }); + } + + // Wait for someone to either complete its perform or trigger a stall + await Promise.race(interesting); + + return await this.#waitForQuorum(running, req); + } + + async _perform(req: PerformActionRequest): Promise { + await this.#initialSync(); + + // Bootstrap enough to meet quorum + const running: Set = new Set(); + for (let i = 0; i < this.quorum; i++) { + this.#addRunner(running, req); + } + + const result = this.#waitForQuorum(running, req); + for (const runner of running) { runner.done = true; } + return result; + } +} diff --git a/src.ts/providers/provider-infura.ts b/src.ts/providers/provider-infura.ts new file mode 100644 index 000000000..6c787e156 --- /dev/null +++ b/src.ts/providers/provider-infura.ts @@ -0,0 +1,89 @@ +import { defineProperties } from "../utils/properties.js"; +import { FetchRequest } from "../utils/fetch.js"; + +import { showThrottleMessage } from "./community.js"; +import { logger } from "../utils/logger.js"; +import { Network } from "./network.js"; +import { JsonRpcProvider } from "./provider-jsonrpc.js"; + +import type { AbstractProvider } from "./abstract-provider.js"; +import type { CommunityResourcable } from "./community.js"; +import type { Networkish } from "./network.js"; + + +const defaultProjectId = "84842078b09946638c03157f83405213"; + +function getHost(name: string): string { + switch(name) { + case "homestead": + return "mainnet.infura.io"; + case "ropsten": + return "ropsten.infura.io"; + case "rinkeby": + return "rinkeby.infura.io"; + case "kovan": + return "kovan.infura.io"; + case "goerli": + return "goerli.infura.io"; + case "matic": + return "polygon-mainnet.infura.io"; + case "maticmum": + return "polygon-mumbai.infura.io"; + case "optimism": + return "optimism-mainnet.infura.io"; + case "optimism-kovan": + return "optimism-kovan.infura.io"; + case "arbitrum": + return "arbitrum-mainnet.infura.io"; + case "arbitrum-rinkeby": + return "arbitrum-rinkeby.infura.io"; + } + + return logger.throwArgumentError("unsupported network", "network", name); +} + + +export class InfuraProvider extends JsonRpcProvider implements CommunityResourcable { + readonly projectId!: string; + readonly projectSecret!: null | string; + + constructor(_network: Networkish = "homestead", projectId?: null | string, projectSecret?: null | string) { + const network = Network.from(_network); + if (projectId == null) { projectId = defaultProjectId; } + if (projectSecret == null) { projectSecret = null; } + + const request = InfuraProvider.getRequest(network, projectId, projectSecret); + super(request, network, { staticNetwork: network }); + + defineProperties(this, { projectId, projectSecret }); + } + + _getProvider(chainId: number): AbstractProvider { + try { + return new InfuraProvider(chainId, this.projectId, this.projectSecret); + } catch (error) { } + return super._getProvider(chainId); + } + + static getRequest(network: Network, projectId?: null | string, projectSecret?: null | string): FetchRequest { + if (projectId == null) { projectId = defaultProjectId; } + if (projectSecret == null) { projectSecret = null; } + + const request = new FetchRequest(`https:/\/${ getHost(network.name) }/v3/${ projectId }`); + request.allowGzip = true; + if (projectSecret) { request.setCredentials("", projectSecret); } + + if (projectId === defaultProjectId) { + request.retryFunc = async (request, response, attempt) => { + showThrottleMessage("InfuraProvider"); + return true; + }; + } + + return request; + } + + isCommunityResource(): boolean { + return (this.projectId === defaultProjectId); + } +} diff --git a/src.ts/providers/provider-ipcsocket-browser.ts b/src.ts/providers/provider-ipcsocket-browser.ts new file mode 100644 index 000000000..4f11ba6a5 --- /dev/null +++ b/src.ts/providers/provider-ipcsocket-browser.ts @@ -0,0 +1,3 @@ +const IpcSocketProvider = undefined; + +export { IpcSocketProvider }; diff --git a/src.ts/providers/provider-ipcsocket.ts b/src.ts/providers/provider-ipcsocket.ts new file mode 100644 index 000000000..3461d2a1a --- /dev/null +++ b/src.ts/providers/provider-ipcsocket.ts @@ -0,0 +1,98 @@ + +import { connect } from "net"; +import { SocketProvider } from "./provider-socket.js"; + +import type { Socket } from "net"; +import type { Networkish } from "./network.js"; + + +// @TODO: Is this sufficient? Is this robust? Will newlines occur between +// all payloads and only between payloads? +function splitBuffer(data: Buffer): { messages: Array, remaining: Buffer } { + const messages: Array = [ ]; + + let lastStart = 0; + while (true) { + const nl = data.indexOf(10, lastStart); + if (nl === -1) { break; } + messages.push(data.subarray(lastStart, nl).toString().trim()); + lastStart = nl + 1; + } + + return { messages, remaining: data.subarray(lastStart) }; +} + +export class IpcSocketProvider extends SocketProvider { + #socket: Socket; + get socket(): Socket { return this.#socket; } + + constructor(path: string, network?: Networkish) { + super(network); + this.#socket = connect(path); + + this.socket.on("ready", () => { this._start(); }); + + let response = Buffer.alloc(0); + this.socket.on("data", (data) => { + response = Buffer.concat([ response, data ]); + const { messages, remaining } = splitBuffer(response); + messages.forEach((message) => { + this._processMessage(message); + }); + response = remaining; + }); + + this.socket.on("end", () => { + this.emit("close"); + this.socket.destroy(); + this.socket.end(); + }); + } + + stop(): void { + this.socket.destroy(); + this.socket.end(); + } + + async _write(message: string): Promise { + console.log(">>>", message); + this.socket.write(message); + } +} +/* + +import { defineProperties } from "@ethersproject/properties"; + +import { SocketLike, SocketProvider } from "./provider-socket.js"; + +import type { Socket } from "net"; + +export class SocketWrapper implements SocketLike { + #socket: Socket; + + constructor(path: string) { + this.#socket = connect(path); + } + + send(data: string): void { + this.#socket.write(data, () => { }); + } + + addEventListener(event: string, listener: (data: string) => void): void { + //this.#socket.on(event, (value: ) => { + //}); + } + + close(): void { + } +} + +export class IpcProvider extends SocketProvider { + readonly path!: string; + + constructor(path: string) { + super(new SocketWrapper(path)); + defineProperties(this, { path }); + } +} +*/ diff --git a/src.ts/providers/provider-jsonrpc.ts b/src.ts/providers/provider-jsonrpc.ts new file mode 100644 index 000000000..a23f1a204 --- /dev/null +++ b/src.ts/providers/provider-jsonrpc.ts @@ -0,0 +1,853 @@ +// @TODO: +// - Add the batching API + +// https://playground.open-rpc.org/?schemaUrl=https://raw.githubusercontent.com/ethereum/eth1.0-apis/assembled-spec/openrpc.json&uiSchema%5BappBar%5D%5Bui:splitView%5D=true&uiSchema%5BappBar%5D%5Bui:input%5D=false&uiSchema%5BappBar%5D%5Bui:examplesDropdown%5D=false + +import { resolveAddress } from "../address/index.js"; +import { hexlify } from "../utils/data.js"; +import { FetchRequest } from "../utils/fetch.js"; +import { toQuantity } from "../utils/maths.js"; +import { defineProperties } from "../utils/properties.js"; +import { TypedDataEncoder } from "../hash/typed-data.js"; +import { toUtf8Bytes } from "../utils/utf8.js"; +import { accessListify } from "../transaction/index.js"; + +import { AbstractProvider, UnmanagedSubscriber } from "./abstract-provider.js"; +import { AbstractSigner } from "./abstract-signer.js"; +import { Network } from "./network.js"; +import { FilterIdEventSubscriber, FilterIdPendingSubscriber } from "./subscriber-filterid.js"; + +import type { TypedDataDomain, TypedDataField } from "../hash/index.js"; +import type { TransactionLike } from "../transaction/index.js"; + +import type { PerformActionRequest, Subscriber, Subscription } from "./abstract-provider.js"; +import type { Networkish } from "./network.js"; +import type { Provider, TransactionRequest, TransactionResponse } from "./provider.js"; +import type { Signer } from "./signer.js"; + +import { logger } from "../utils/logger.js"; + + +//function copy(value: T): T { +// return JSON.parse(JSON.stringify(value)); +//} + +const Primitive = "bigint,boolean,function,number,string,symbol".split(/,/g); +//const Methods = "getAddress,then".split(/,/g); +function deepCopy(value: T): T { + if (value == null || Primitive.indexOf(typeof(value)) >= 0) { + return value; + } + + // Keep any Addressable + if (typeof((value).getAddress) === "function") { + return value; + } + + if (Array.isArray(value)) { return (value.map(deepCopy)); } + + if (typeof(value) === "object") { + return Object.keys(value).reduce((accum, key) => { + accum[key] = (value)[key]; + return accum; + }, { }); + } + + throw new Error(`should not happen: ${ value } (${ typeof(value) })`); +} + +function getLowerCase(value: string): string { + if (value) { return value.toLowerCase(); } + return value; +} + +interface Pollable { + pollingInterval: number; +} + +function isPollable(value: any): value is Pollable { + return (value && typeof(value.pollingInterval) === "number"); +} + +export type JsonRpcPayload = { + id: number; + method: string; + params: Array | Record; + jsonrpc: "2.0"; +}; + +export type JsonRpcResult = { + id: number; + result: any; +}; + +export type JsonRpcError = { + id: number; + error: { + code: number; + message?: string; + data?: any; + } +}; + +export type JsonRpcOptions = { + // Whether to immediately fallback onto useing the polling strategy; otherwise + // attempt to use filters first, falling back onto polling if filter returns failure + polling?: boolean; + + // Whether to check the network on each call; only set this to true when a backend + // **cannot** change otherwise catastrophic errors can occur + staticNetwork?: null | Network; + + // How long to wait before draining the payload queue + batchStallTime?: number; + + // Maximum estimated size (in bytes) to allow in a batch + batchMaxSize?: number; + + // Maximum number of payloads to send per batch; if set to 1, non-batching requests + // are made. + batchMaxCount?: number; +}; + +const defaultOptions = { + polling: false, + staticNetwork: null, + + batchStallTime: 10, // 10ms + batchMaxSize: (1 << 20), // 1Mb + batchMaxCount: 100 // 100 requests +} + +export interface JsonRpcTransactionRequest { + from?: string; + to?: string; + data?: string; + + chainId?: string; + type?: string; + gas?: string; + + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + + nonce?: string; + value?: string; + + accessList?: Array<{ address: string, storageKeys: Array }>; +} + +// @TODO: Unchecked Signers + +export class JsonRpcSigner extends AbstractSigner { + address!: string; + + constructor(provider: JsonRpcApiProvider, address: string) { + super(provider); + defineProperties(this, { address }); + } + + connect(provider: null | Provider): Signer { + return logger.throwError("cannot reconnect JsonRpcSigner", "UNSUPPORTED_OPERATION", { + operation: "signer.connect" + }); + } + + async getAddress(): Promise { + return this.address; + } + + // JSON-RPC will automatially fill in nonce, etc. so we just check from + async populateTransaction(tx: TransactionRequest): Promise> { + return await this.populateCall(tx); + } + + //async getNetwork(): Promise> { + // return await this.provider.getNetwork(); + //} + + //async estimateGas(tx: TransactionRequest): Promise { + // return await this.provider.estimateGas(tx); + //} + + //async call(tx: TransactionRequest): Promise { + // return await this.provider.call(tx); + //} + + //async resolveName(name: string | Addressable): Promise { + // return await this.provider.resolveName(name); + //} + + //async getNonce(blockTag?: BlockTag): Promise { + // return await this.provider.getTransactionCountOf(this.address); + //} + + // Returns just the hash of the transaction after sent, which is what + // the bare JSON-RPC API does; + async sendUncheckedTransaction(_tx: TransactionRequest): Promise { + const tx = deepCopy(_tx); + + const promises: Array> = []; + + // Make sure the from matches the sender + if (tx.from) { + const _from = tx.from; + promises.push((async () => { + const from = await resolveAddress(_from, this.provider); + if (from == null || from.toLowerCase() !== this.address.toLowerCase()) { + logger.throwArgumentError("from address mismatch", "transaction", _tx); + } + tx.from = from; + })()); + } else { + tx.from = this.address; + } + + // The JSON-RPC for eth_sendTransaction uses 90000 gas; if the user + // wishes to use this, it is easy to specify explicitly, otherwise + // we look it up for them. + if (tx.gasLimit == null) { + promises.push((async () => { + tx.gasLimit = await this.provider.estimateGas({ ...tx, from: this.address}); + })()); + } + + // The address may be an ENS name or Addressable + if (tx.to != null) { + const _to = tx.to; + promises.push((async () => { + tx.to = await resolveAddress(_to, this.provider); + })()); + } + + // Wait until all of our properties are filled in + if (promises.length) { await Promise.all(promises); } + + const hexTx = this.provider.getRpcTransaction(tx); + + return this.provider.send("eth_sendTransaction", [ hexTx ]); + } + + async sendTransaction(tx: TransactionRequest): Promise { + // This cannot be mined any earlier than any recent block + const blockNumber = await this.provider.getBlockNumber(); + + // Send the transaction + const hash = await this.sendUncheckedTransaction(tx); + + // Unfortunately, JSON-RPC only provides and opaque transaction hash + // for a response, and we need the actual transaction, so we poll + // for it; it should show up very quickly + return await (new Promise((resolve, reject) => { + const timeouts = [ 1000, 100 ]; + const checkTx = async () => { + // Try getting the transaction + const tx = await this.provider.getTransaction(hash); + if (tx != null) { + resolve(this.provider._wrapTransaction(tx, hash, blockNumber)); + return; + } + + // Wait another 4 seconds + this.provider._setTimeout(() => { checkTx(); }, timeouts.pop() || 4000); + }; + checkTx(); + })); + } + + async signTransaction(_tx: TransactionRequest): Promise { + const tx = deepCopy(_tx); + + // Make sure the from matches the sender + if (tx.from) { + const from = await resolveAddress(tx.from, this.provider); + if (from == null || from.toLowerCase() !== this.address.toLowerCase()) { + return logger.throwArgumentError("from address mismatch", "transaction", _tx); + } + tx.from = from; + } else { + tx.from = this.address; + } + + const hexTx = this.provider.getRpcTransaction(tx); + return await this.provider.send("eth_sign_Transaction", [ hexTx ]); + } + + + async signMessage(_message: string | Uint8Array): Promise { + const message = ((typeof(_message) === "string") ? toUtf8Bytes(_message): _message); + return await this.provider.send("personal_sign", [ + hexlify(message), this.address.toLowerCase() ]); + } + + async signTypedData(domain: TypedDataDomain, types: Record>, _value: Record): Promise { + const value = deepCopy(_value); + + // Populate any ENS names (in-place) + const populated = await TypedDataEncoder.resolveNames(domain, types, value, async (value: string) => { + const address = await resolveAddress(value); + if (address == null) { + return logger.throwArgumentError("TypedData does not support null address", "value", value); + } + return address; + }); + + return await this.provider.send("eth_signTypedData_v4", [ + this.address.toLowerCase(), + JSON.stringify(TypedDataEncoder.getPayload(populated.domain, types, populated.value)) + ]); + } + + async unlock(password: string): Promise { + return this.provider.send("personal_unlockAccount", [ + this.address.toLowerCase(), password, null ]); + } + + // https://github.com/ethereum/wiki/wiki/JSON-RPC#eth_sign + async _legacySignMessage(_message: string | Uint8Array): Promise { + const message = ((typeof(_message) === "string") ? toUtf8Bytes(_message): _message); + return await this.provider.send("eth_sign", [ + this.address.toLowerCase(), hexlify(message) ]); + } +} + +type ResolveFunc = (result: JsonRpcResult) => void; +type RejectFunc = (error: Error) => void; + +type Payload = { payload: JsonRpcPayload, resolve: ResolveFunc, reject: RejectFunc }; + +export class JsonRpcApiProvider extends AbstractProvider { + + #options: Required; + + #nextId: number; + #payloads: Array; + #drainTimer: null | NodeJS.Timer; + + constructor(network?: Networkish, options?: JsonRpcOptions) { + super(network); + + this.#nextId = 1; + this.#options = Object.assign({ }, defaultOptions, options || { }); + + this.#payloads = [ ]; + this.#drainTimer = null; + + // This could be relaxed in the future to just check equivalent networks + const staticNetwork = this._getOption("staticNetwork"); + if (staticNetwork && staticNetwork !== network) { + logger.throwArgumentError("staticNetwork MUST match network object", "options", options); + } + } + + _getOption(key: K): JsonRpcOptions[K] { + return this.#options[key]; + } + + // @TODO: Merge this into send + //prepareRequest(method: string, params: Array): JsonRpcPayload { + // return { + // method, params, id: (this.#nextId++), jsonrpc: "2.0" + // }; + //} +/* + async send(method: string, params: Array): Promise { + // @TODO: This should construct and queue the payload + throw new Error("sub-class must implement this"); + } +*/ + + #scheduleDrain(): void { + if (this.#drainTimer) { return; } + + this.#drainTimer = setTimeout(() => { + this.#drainTimer = null; + + const payloads = this.#payloads; + this.#payloads = [ ]; + + while (payloads.length) { + + // Create payload batches that satisfy our batch constraints + const batch = [ (payloads.shift()) ]; + while (payloads.length) { + if (batch.length === this.#options.batchMaxCount) { break; } + batch.push((payloads.shift())); + const bytes = JSON.stringify(batch.map((p) => p.payload)); + if (bytes.length > this.#options.batchMaxSize) { + payloads.unshift((batch.pop())); + break; + } + } + + // Process the result to each payload + (async () => { + const payload = ((batch.length === 1) ? batch[0].payload: batch.map((p) => p.payload)); + + this.emit("debug", { action: "sendRpcPayload", payload }); + + try { + const result = await this._send(payload); + this.emit("debug", { action: "receiveRpcResult", result }); + + // Process results in batch order + for (const { resolve, reject, payload } of batch) { + + // Find the matching result + const resp = result.filter((r) => (r.id === payload.id))[0]; + + // No result; the node failed us in unexpected ways + if (resp == null) { + return reject(new Error("@TODO: no result")); + } + + // The response is an error + if ("error" in resp) { + return reject(this.getRpcError(payload, resp)); + + } + + // All good; send the result + resolve(resp.result); + } + + } catch (error: any) { + this.emit("debug", { action: "receiveRpcError", error }); + + for (const { reject } of batch) { + // @TODO: augment the error with the payload + reject(error); + } + } + })(); + } + }, this.#options.batchStallTime); + } + + // Sub-classes should **NOT** override this + send(method: string, params: Array | Record): Promise { + // @TODO: cache chainId?? purge on switch_networks + + const id = this.#nextId++; + const promise = new Promise((resolve, reject) => { + this.#payloads.push({ + resolve, reject, + payload: { method, params, id, jsonrpc: "2.0" } + }); + }); + + // If there is not a pending drainTimer, set one + this.#scheduleDrain(); + + return >promise; + } + + // Sub-classes MUST override this + _send(payload: JsonRpcPayload | Array): Promise> { + return logger.throwError("sub-classes must override _send", "UNSUPPORTED_OPERATION", { + operation: "jsonRpcApiProvider._send" + }); + } + + async getSigner(address: number | string = 0): Promise { + + const accountsPromise = this.send("eth_accounts", [ ]); + + // Account index + if (typeof(address) === "number") { + const accounts = >(await accountsPromise); + if (address > accounts.length) { throw new Error("no such account"); } + return new JsonRpcSigner(this, accounts[address]); + } + + const [ network, accounts ] = await Promise.all([ this.getNetwork(), accountsPromise ]); + + // Account address + address = network.formatter.address(address); + for (const account of accounts) { + if (network.formatter.address(account) === account) { + return new JsonRpcSigner(this, account); + } + } + + throw new Error("invalid account"); + } + + // Sub-classes can override this; it detects the *actual* network we + // are connected to + async _detectNetwork(): Promise { + // We have a static network (like INFURA) + const network = this._getOption("staticNetwork"); + if (network) { return network; } + + return Network.from(logger.getBigInt(await this._perform({ method: "chainId" }))); + } + + _getSubscriber(sub: Subscription): Subscriber { + // Pending Filters aren't availble via polling + if (sub.type === "pending") { return new FilterIdPendingSubscriber(this); } + + if (sub.type === "event") { + return new FilterIdEventSubscriber(this, sub.filter); + } + + // Orphaned Logs are handled automatically, by the filter, since + // logs with removed are emitted by it + if (sub.type === "orphan" && sub.filter.orphan === "drop-log") { + return new UnmanagedSubscriber("orphan"); + } + + return super._getSubscriber(sub); + } + + // Normalize a JSON-RPC transaction + getRpcTransaction(tx: TransactionRequest): JsonRpcTransactionRequest { + const result: JsonRpcTransactionRequest = {}; + + // JSON-RPC now requires numeric values to be "quantity" values + ["chainId", "gasLimit", "gasPrice", "type", "maxFeePerGas", "maxPriorityFeePerGas", "nonce", "value"].forEach((key) => { + if ((tx)[key] == null) { return; } + let dstKey = key; + if (key === "gasLimit") { dstKey = "gas"; } + (result)[dstKey] = toQuantity(logger.getBigInt((tx)[key], `tx.${ key }`)); + }); + + // Make sure addresses and data are lowercase + ["from", "to", "data"].forEach((key) => { + if ((tx)[key] == null) { return; } + (result)[key] = hexlify((tx)[key]); + }); + + // Normalize the access list object + if (tx.accessList) { + result["accessList"] = accessListify(tx.accessList); + } + + return result; + } + + // Get the necessary paramters for making a JSON-RPC request + getRpcRequest(req: PerformActionRequest): null | { method: string, args: Array } { + switch (req.method) { + case "chainId": + return { method: "eth_chainId", args: [ ] }; + + case "getBlockNumber": + return { method: "eth_blockNumber", args: [ ] }; + + case "getGasPrice": + return { method: "eth_gasPrice", args: [] }; + + case "getBalance": + return { + method: "eth_getBalance", + args: [ getLowerCase(req.address), req.blockTag ] + }; + + case "getTransactionCount": + return { + method: "eth_getTransactionCount", + args: [ getLowerCase(req.address), req.blockTag ] + }; + + case "getCode": + return { + method: "eth_getCode", + args: [ getLowerCase(req.address), req.blockTag ] + }; + + case "getStorageAt": + return { + method: "eth_getStorageAt", + args: [ + getLowerCase(req.address), + ("0x" + req.position.toString(16)), + req.blockTag + ] + }; + + case "broadcastTransaction": + return { + method: "eth_sendRawTransaction", + args: [ req.signedTransaction ] + }; + + case "getBlock": + if ("blockTag" in req) { + return { + method: "eth_getBlockByNumber", + args: [ req.blockTag, !!req.includeTransactions ] + }; + } else if ("blockHash" in req) { + return { + method: "eth_getBlockByHash", + args: [ req.blockHash, !!req.includeTransactions ] + }; + } + break; + + case "getTransaction": + return { + method: "eth_getTransactionByHash", + args: [ req.hash ] + }; + + case "getTransactionReceipt": + return { + method: "eth_getTransactionReceipt", + args: [ req.hash ] + }; + + case "call": + return { + method: "eth_call", + args: [ this.getRpcTransaction(req.transaction), req.blockTag ] + }; + + case "estimateGas": { + return { + method: "eth_estimateGas", + args: [ this.getRpcTransaction(req.transaction) ] + }; + } + + case "getLogs": + if (req.filter && req.filter.address != null) { + if (Array.isArray(req.filter.address)) { + req.filter.address = req.filter.address.map(getLowerCase); + } else { + req.filter.address = getLowerCase(req.filter.address); + } + } + return { method: "eth_getLogs", args: [ req.filter ] }; + } + + return null; + } + + getRpcError(payload: JsonRpcPayload, error: JsonRpcError): Error { + console.log("getRpcError", payload, error); + return new Error(`JSON-RPC badness; @TODO: ${ error }`); + /* + if (payload.method === "eth_call") { + const result = spelunkData(error); + if (result) { + // @TODO: Extract errorSignature, errorName, errorArgs, reason if + // it is Error(string) or Panic(uint25) + return logger.makeError("execution reverted during JSON-RPC call", "CALL_EXCEPTION", { + data: result.data, + transaction: args[0] + }); + } + + return logger.makeError("missing revert data during JSON-RPC call", "CALL_EXCEPTION", { + data: "0x", transaction: args[0], info: { error } + }); + } + + if (method === "eth_estimateGas") { + // @TODO: Spelunk, and adapt the above to allow missing data. + // Then throw an UNPREDICTABLE_GAS exception + } + + const message = JSON.stringify(spelunkMessage(error)); + + if (message.match(/insufficient funds|base fee exceeds gas limit/)) { + return logger.makeError("insufficient funds for intrinsic transaction cost", "INSUFFICIENT_FUNDS", { + transaction: args[0] + }); + } + + if (message.match(/nonce/) && message.match(/too low/)) { + return logger.makeError("nonce has already been used", "NONCE_EXPIRED", { + transaction: args[0] + }); + } + + // "replacement transaction underpriced" + if (message.match(/replacement transaction/) && message.match(/underpriced/)) { + return logger.makeError("replacement fee too low", "REPLACEMENT_UNDERPRICED", { + transaction: args[0] + }); + } + + if (message.match(/only replay-protected/)) { + return logger.makeError("legacy pre-eip-155 transactions not supported", "UNSUPPORTED_OPERATION", { + operation: method, info: { transaction: args[0] } + }); + } + + if (method === "estimateGas" && message.match(/gas required exceeds allowance|always failing transaction|execution reverted/)) { + return logger.makeError("cannot estimate gas; transaction may fail or may require manual gas limit", "UNPREDICTABLE_GAS_LIMIT", { + transaction: args[0] + }); + } + + return error; + */ + } + + async _perform(req: PerformActionRequest): Promise { + // Legacy networks do not like the type field being passed along (which + // is fair), so we delete type if it is 0 and a non-EIP-1559 network + if (req.method === "call" || req.method === "estimateGas") { + let tx = req.transaction; + if (tx && tx.type != null && logger.getBigInt(tx.type)) { + // If there are no EIP-1559 properties, it might be non-EIP-a559 + if (tx.maxFeePerGas == null && tx.maxPriorityFeePerGas == null) { + const feeData = await this.getFeeData(); + if (feeData.maxFeePerGas == null && feeData.maxPriorityFeePerGas == null) { + // Network doesn't know about EIP-1559 (and hence type) + req = Object.assign({ }, req, { + transaction: Object.assign({ }, tx, { type: undefined }) + }); + } + } + } + } + + const request = this.getRpcRequest(req); + + if (request != null) { + return await this.send(request.method, request.args); + +/* + @TODO: Add debug output to send + this.emit("debug", { type: "sendRequest", request }); + try { + const result = + //console.log("RR", result); + this.emit("debug", { type: "getResponse", result }); + return result; + } catch (error) { + this.emit("debug", { type: "getError", error }); + throw error; + //throw this.getRpcError(request.method, request.args, error); + } +*/ + } + + return super._perform(req); + } +} + +export class JsonRpcProvider extends JsonRpcApiProvider { + #connect: FetchRequest; + + #pollingInterval: number; + + constructor(url?: string | FetchRequest, network?: Networkish, options?: JsonRpcOptions) { + if (url == null) { url = "http:/\/localhost:8545"; } + super(network, options); + + if (typeof(url) === "string") { + this.#connect = new FetchRequest(url); + } else { + this.#connect = url.clone(); + } + + this.#pollingInterval = 4000; + } + + async _send(payload: JsonRpcPayload | Array): Promise> { + // Configure a POST connection for the requested method + const request = this.#connect.clone(); + request.body = JSON.stringify(payload); + + const response = await request.send(); + response.assertOk(); + + let resp = response.bodyJson; + if (!Array.isArray(resp)) { resp = [ resp ]; } + + return resp; + } + + get pollingInterval(): number { return this.#pollingInterval; } + set pollingInterval(value: number) { + if (!Number.isInteger(value) || value < 0) { throw new Error("invalid interval"); } + this.#pollingInterval = value; + this._forEachSubscriber((sub) => { + if (isPollable(sub)) { + sub.pollingInterval = this.#pollingInterval; + } + }); + } +} + +// This class should only be used when it is not possible for the +// underlying network to change, such as with INFURA. If you are +// using MetaMask or some other client which allows users to change +// their network DO NOT USE THIS. Bad things will happen. +/* +export class StaticJsonRpcProvider extends JsonRpcProvider { + readonly network!: Network; + + constructor(url: string | ConnectionInfo, network?: Network, options?: JsonRpcOptions) { + super(url, network, options); + defineProperties(this, { network }); + } + + async _detectNetwork(): Promise { + return this.network; + } +} +*/ +/* +function spelunkData(value: any): null | { message: string, data: string } { + if (value == null) { return null; } + + // These *are* the droids we're looking for. + if (typeof(value.message) === "string" && value.message.match("reverted") && isHexString(value.data)) { + return { message: value.message, data: value.data }; + } + + // Spelunk further... + if (typeof(value) === "object") { + for (const key in value) { + const result = spelunkData(value[key]); + if (result) { return result; } + } + return null; + } + + // Might be a JSON string we can further descend... + if (typeof(value) === "string") { + try { + return spelunkData(JSON.parse(value)); + } catch (error) { } + } + + return null; +} + +function _spelunkMessage(value: any, result: Array): void { + if (value == null) { return; } + + // These *are* the droids we're looking for. + if (typeof(value.message) === "string") { + result.push(value.message); + } + + // Spelunk further... + if (typeof(value) === "object") { + for (const key in value) { + _spelunkMessage(value[key], result); + } + } + + // Might be a JSON string we can further descend... + if (typeof(value) === "string") { + try { + return _spelunkMessage(JSON.parse(value), result); + } catch (error) { } + } +} + +function spelunkMessage(value: any): Array { + const result: Array = [ ]; + _spelunkMessage(value, result); + return result; +} +*/ diff --git a/src.ts/providers/provider-pocket.ts b/src.ts/providers/provider-pocket.ts new file mode 100644 index 000000000..9b6e3eeb6 --- /dev/null +++ b/src.ts/providers/provider-pocket.ts @@ -0,0 +1,99 @@ +/* +import { defineProperties } from "../utils/properties.js"; +import { FetchRequest } from "../web/index.js"; + +import { showThrottleMessage } from "./community.js"; +import { logger } from "../utils/logger.js"; +import { Network } from "./network.js"; +import { JsonRpcProvider } from "./provider-jsonrpc.js"; + +import type { ConnectionInfo, ThrottleRetryFunc } from "../web/index.js"; + +import type { CommunityResourcable } from "./community.js"; +import type { Networkish } from "./network.js"; + + + +// These are load-balancer-based application IDs +const defaultAppIds: Record = { + homestead: "6004bcd10040261633ade990", + ropsten: "6004bd4d0040261633ade991", + rinkeby: "6004bda20040261633ade994", + goerli: "6004bd860040261633ade992", +}; + +function getHost(name: string): string { + switch(name) { + case "homestead": + return "eth-mainnet.gateway.pokt.network"; + case "ropsten": + return "eth-ropsten.gateway.pokt.network"; + case "rinkeby": + return "eth-rinkeby.gateway.pokt.network"; + case "goerli": + return "eth-goerli.gateway.pokt.network"; + } + + return logger.throwArgumentError("unsupported network", "network", name); +} + +function normalizeApiKey(network: Network, _appId?: null | string, applicationSecretKey?: null | string, loadBalancer?: boolean): { applicationId: string, applicationSecretKey: null | string, loadBalancer: boolean, community: boolean } { + loadBalancer = !!loadBalancer; + + let community = false; + let applicationId = _appId; + if (applicationId == null) { + applicationId = defaultAppIds[network.name]; + if (applicationId == null) { + logger.throwArgumentError("network does not support default applicationId", "applicationId", _appId); + } + loadBalancer = true; + community = true; + } else if (applicationId === defaultAppIds[network.name]) { + loadBalancer = true; + community = true; + } + if (applicationSecretKey == null) { applicationSecretKey = null; } + + return { applicationId, applicationSecretKey, community, loadBalancer }; +} + +export class PocketProvider extends JsonRpcProvider implements CommunityResourcable { + readonly applicationId!: string; + readonly applicationSecretKey!: null | string; + readonly loadBalancer!: boolean; + + constructor(_network: Networkish = "homestead", _appId?: null | string, _secretKey?: null | string, _loadBalancer?: boolean) { + const network = Network.from(_network); + const { applicationId, applicationSecretKey, loadBalancer } = normalizeApiKey(network, _appId, _secretKey, _loadBalancer); + + const connection = PocketProvider.getConnection(network, applicationId, applicationSecretKey, loadBalancer); + super(connection, network, { staticNetwork: network }); + + defineProperties(this, { applicationId, applicationSecretKey, loadBalancer }); + } + + static getConnection(network: Network, _appId?: null | string, _secretKey?: null | string, _loadBalancer?: boolean): ConnectionInfo { + const { applicationId, applicationSecretKey, community, loadBalancer } = normalizeApiKey(network, _appId, _secretKey, _loadBalancer); + + let url = `https:/\/${ getHost(network.name) }/v1/`; + if (loadBalancer) { url += "lb/"; } + url += applicationId; + + const request = new FetchRequest(url); + request.allowGzip = true; + if (applicationSecretKey) { request.setCredentials("", applicationSecretKey); } + + const throttleRetry: ThrottleRetryFunc = async (request, response, attempt) => { + if (community) { showThrottleMessage("PocketProvider"); } + return true; + }; + + return { request, throttleRetry }; + } + + isCommunityResource(): boolean { + return (this.applicationId === defaultAppIds[this.network.name]); + } +} +*/ diff --git a/src.ts/providers/provider-socket.ts b/src.ts/providers/provider-socket.ts new file mode 100644 index 000000000..c37163d80 --- /dev/null +++ b/src.ts/providers/provider-socket.ts @@ -0,0 +1,259 @@ +/** + * SocketProvider + * + * Generic long-lived socket provider. + * + * Sub-classing notes + * - a sub-class MUST call the `_start()` method once connected + * - a sub-class MUST override the `_write(string)` method + * - a sub-class MUST call `_processMessage(string)` for each message + */ + +import { UnmanagedSubscriber } from "./abstract-provider.js"; +import { assertArgument, logger } from "../utils/logger.js"; +import { JsonRpcApiProvider } from "./provider-jsonrpc.js"; + +import type { Subscriber, Subscription } from "./abstract-provider.js"; +import type { Formatter } from "./formatter.js"; +import type { EventFilter } from "./provider.js"; +import type { JsonRpcError, JsonRpcPayload, JsonRpcResult } from "./provider-jsonrpc.js"; +import type { Networkish } from "./network.js"; + + +type JsonRpcSubscription = { + method: string, + params: { + result: any, + subscription: string + } +}; + +export class SocketSubscriber implements Subscriber { + #provider: SocketProvider; + + #filter: string; + get filter(): Array { return JSON.parse(this.#filter); } + + #filterId: null | Promise; + #paused: null | boolean; + + #emitPromise: null | Promise; + + constructor(provider: SocketProvider, filter: Array) { + this.#provider = provider; + this.#filter = JSON.stringify(filter); + this.#filterId = null; + this.#paused = null; + this.#emitPromise = null; + } + + start(): void { + this.#filterId = this.#provider.send("eth_subscribe", this.filter).then((filterId) => {; + this.#provider._register(filterId, this); + return filterId; + }); + } + + stop(): void { + (>(this.#filterId)).then((filterId) => { + this.#provider.send("eth_unsubscribe", [ filterId ]); + }); + this.#filterId = null; + } + + // @TODO: pause should trap the current blockNumber, unsub, and on resume use getLogs + // and resume + pause(dropWhilePaused?: boolean): void { + if (!dropWhilePaused) { + logger.throwError("preserve logs while paused not supported by SocketSubscriber yet", "UNSUPPORTED_OPERATION", { + operation: "pause(false)" + }); + } + this.#paused = !!dropWhilePaused; + } + + resume(): void { + this.#paused = null; + } + + _handleMessage(message: any): void { + if (this.#filterId == null) { return; } + if (this.#paused === null) { + let emitPromise: null | Promise = this.#emitPromise; + if (emitPromise == null) { + emitPromise = this._emit(this.#provider, message); + } else { + emitPromise = emitPromise.then(async () => { + await this._emit(this.#provider, message); + }); + } + this.#emitPromise = emitPromise.then(() => { + if (this.#emitPromise === emitPromise) { + this.#emitPromise = null; + } + }); + } + } + + async _emit(provider: SocketProvider, message: any): Promise { + throw new Error("sub-classes must implemente this; _emit"); + } +} + +export class SocketBlockSubscriber extends SocketSubscriber { + constructor(provider: SocketProvider) { + super(provider, [ "newHeads" ]); + } + + async _emit(provider: SocketProvider, message: any): Promise { + provider.emit("block", parseInt(message.number)); + } +} + +export class SocketPendingSubscriber extends SocketSubscriber { + constructor(provider: SocketProvider) { + super(provider, [ "newPendingTransactions" ]); + } + + async _emit(provider: SocketProvider, message: any): Promise { + provider.emit("pending", message); + } +} + +export class SocketEventSubscriber extends SocketSubscriber { + #logFilter: string; + get logFilter(): EventFilter { return JSON.parse(this.#logFilter); } + + #formatter: Promise>; + + constructor(provider: SocketProvider, filter: EventFilter) { + super(provider, [ "logs", filter ]); + this.#logFilter = JSON.stringify(filter); + this.#formatter = provider.getNetwork().then((network) => network.formatter); + } + + async _emit(provider: SocketProvider, message: any): Promise { + const formatter = await this.#formatter; + provider.emit(this.#logFilter, formatter.log(message, provider)); + } +} + +export class SocketProvider extends JsonRpcApiProvider { + #callbacks: Map void, reject: (e: Error) => void }>; + + #ready: boolean; + + // Maps each filterId to its subscriber + #subs: Map; + + // If any events come in before a subscriber has finished + // registering, queue them + #pending: Map>; + + constructor(network?: Networkish) { + super(network, { batchMaxCount: 1 }); + this.#callbacks = new Map(); + this.#ready = false; + this.#subs = new Map(); + this.#pending = new Map(); + } + + _getSubscriber(sub: Subscription): Subscriber { + switch (sub.type) { + case "close": + return new UnmanagedSubscriber("close"); + case "block": + return new SocketBlockSubscriber(this); + case "pending": + return new SocketPendingSubscriber(this); + case "event": + return new SocketEventSubscriber(this, sub.filter); + case "orphan": + // Handled auto-matically within AbstractProvider + // when the log.removed = true + if (sub.filter.orphan === "drop-log") { + return new UnmanagedSubscriber("drop-log"); + } + } + return super._getSubscriber(sub); + } + + _register(filterId: number | string, subscriber: SocketSubscriber): void { + this.#subs.set(filterId, subscriber); + const pending = this.#pending.get(filterId); + if (pending) { + for (const message of pending) { + subscriber._handleMessage(message); + } + this.#pending.delete(filterId); + } + } + + async _send(payload: JsonRpcPayload | Array): Promise> { + // WebSocket provider doesn't accept batches + assertArgument(!Array.isArray(payload), "WebSocket does not support batch send", "payload", payload); + + // @TODO: stringify payloads here and store to prevent mutations + const promise = new Promise((resolve, reject) => { + this.#callbacks.set(payload.id, { payload, resolve, reject }); + }); + + if (this.#ready) { + await this._write(JSON.stringify(payload)); + } + + return >[ await promise ]; + } + + // Sub-classes must call this once they are connected + async _start(): Promise { + if (this.#ready) { return; } + this.#ready = true; + for (const { payload } of this.#callbacks.values()) { + await this._write(JSON.stringify(payload)); + } + } + + // Sub-classes must call this for each message + async _processMessage(message: string): Promise { + const result = (JSON.parse(message)); + + if ("id" in result) { + const callback = this.#callbacks.get(result.id); + if (callback == null) { + console.log("Weird... Response for not a thing we sent"); + return; + } + this.#callbacks.delete(result.id); + + if ("error" in result) { + const { message, code, data } = result.error; + const error = logger.makeError(message || "unkonwn error", "SERVER_ERROR", { + request: `ws:${ JSON.stringify(callback.payload) }`, + info: { code, data } + }); + callback.reject(error); + } else { + callback.resolve(result.result); + } + + } else if (result.method === "eth_subscription") { + const filterId = result.params.subscription; + const subscriber = this.#subs.get(filterId); + if (subscriber) { + subscriber._handleMessage(result.params.result); + } else { + let pending = this.#pending.get(filterId); + if (pending == null) { + pending = [ ]; + this.#pending.set(filterId, pending); + } + pending.push(result.params.result); + } + } + } + + async _write(message: string): Promise { + throw new Error("sub-classes must override this"); + } +} diff --git a/src.ts/providers/provider-websocket.ts b/src.ts/providers/provider-websocket.ts new file mode 100644 index 000000000..1774d68d6 --- /dev/null +++ b/src.ts/providers/provider-websocket.ts @@ -0,0 +1,46 @@ + + +import { WebSocket as _WebSocket } from "./ws.js"; /*-browser*/ + +import { SocketProvider } from "./provider-socket.js"; + +import type { Networkish } from "./network.js"; + +export interface WebSocketLike { + onopen: null | ((...args: Array) => any); + onmessage: null | ((...args: Array) => any); + onerror: null | ((...args: Array) => any); + + readyState: number; + + send(payload: any): void; + close(code?: number, reason?: string): void; +} + +export class WebSocketProvider extends SocketProvider { + url!: string; + + #websocket: WebSocketLike; + get websocket(): WebSocketLike { return this.#websocket; } + + constructor(url: string | WebSocketLike, network?: Networkish) { + super(network); + if (typeof(url) === "string") { + this.#websocket = new _WebSocket(url); + } else { + this.#websocket = url; + } + + this.websocket.onopen = () => { + this._start(); + }; + + this.websocket.onmessage = (message: { data: string }) => { + this._processMessage(message.data); + }; + } + + async _write(message: string): Promise { + this.websocket.send(message); + } +} diff --git a/src.ts/providers/provider.ts b/src.ts/providers/provider.ts new file mode 100644 index 000000000..b238f0b7a --- /dev/null +++ b/src.ts/providers/provider.ts @@ -0,0 +1,1097 @@ +//import { resolveAddress } from "@ethersproject/address"; +import { hexlify } from "../utils/data.js"; +import { logger } from "../utils/logger.js"; +import { defineProperties } from "../utils/properties.js"; +import { accessListify } from "../transaction/index.js"; + + +import type { AddressLike, NameResolver } from "../address/index.js"; +import type { BigNumberish, EventEmitterable, Frozen, Listener } from "../utils/index.js"; +import type { Signature } from "../crypto/index.js"; +import type { AccessList, AccessListish, TransactionLike } from "../transaction/index.js"; + +import type { ContractRunner } from "./contracts.js"; +import type { Network } from "./network.js"; + + +export type BlockTag = number | string; + +// ----------------------- + +function getValue(value: undefined | null | T): null | T { + if (value == null) { return null; } + return value; +} + +function toJson(value: null | bigint): null | string { + if (value == null) { return null; } + return value.toString(); +} + +// @TODO? implements Required +export class FeeData { + readonly gasPrice!: null | bigint; + readonly maxFeePerGas!: null | bigint; + readonly maxPriorityFeePerGas!: null | bigint; + + constructor(gasPrice?: null | bigint, maxFeePerGas?: null | bigint, maxPriorityFeePerGas?: null | bigint) { + defineProperties(this, { + gasPrice: getValue(gasPrice), + maxFeePerGas: getValue(maxFeePerGas), + maxPriorityFeePerGas: getValue(maxPriorityFeePerGas) + }); + } + + toJSON(): any { + const { + gasPrice, maxFeePerGas, maxPriorityFeePerGas + } = this; + return { + _type: "FeeData", + gasPrice: toJson(gasPrice), + maxFeePerGas: toJson(maxFeePerGas), + maxPriorityFeePerGas: toJson(maxPriorityFeePerGas), + }; + } +} + + + +export interface TransactionRequest { + type?: null | number; + + to?: null | AddressLike; + from?: null | AddressLike; + + nonce?: null | number; + + gasLimit?: null | BigNumberish; + gasPrice?: null | BigNumberish; + + maxPriorityFeePerGas?: null | BigNumberish; + maxFeePerGas?: null | BigNumberish; + + data?: null | string; + value?: null | BigNumberish; + chainId?: null | BigNumberish; + + accessList?: null | AccessListish; + + customData?: any; + + // Todo? + //gasMultiplier?: number; +}; + + +export interface CallRequest extends TransactionRequest { + blockTag?: BlockTag; + enableCcipRead?: boolean; +} + +export interface PreparedRequest { + type?: number; + + to?: AddressLike; + from?: AddressLike; + + nonce?: number; + + gasLimit?: bigint; + gasPrice?: bigint; + + maxPriorityFeePerGas?: bigint; + maxFeePerGas?: bigint; + + data?: string; + value?: bigint; + chainId?: bigint; + + accessList?: AccessList; + + customData?: any; + + blockTag?: BlockTag; + enableCcipRead?: boolean; +} + +export function copyRequest(req: CallRequest): PreparedRequest { + const result: any = { }; + + // These could be addresses, ENS names or Addressables + if (req.to) { result.to = req.to; } + if (req.from) { result.from = req.from; } + + if (req.data) { result.data = hexlify(req.data); } + + const bigIntKeys = "chainId,gasLimit,gasPrice,maxFeePerGas, maxPriorityFeePerGas,value".split(/,/); + for (const key in bigIntKeys) { + if (!(key in req) || (req)[key] == null) { continue; } + result[key] = logger.getBigInt((req)[key], `request.${ key }`); + } + + const numberKeys = "type,nonce".split(/,/); + for (const key in numberKeys) { + if (!(key in req) || (req)[key] == null) { continue; } + result[key] = logger.getNumber((req)[key], `request.${ key }`); + } + + if (req.accessList) { + result.accessList = accessListify(req.accessList); + } + + if ("blockTag" in req) { result.blockTag = req.blockTag; } + + if ("enableCcipRead" in req) { + result.enableCcipReadEnabled = !!req.enableCcipRead + } + + if ("customData" in req) { + result.customData = req.customData; + } + + return result; +} + +//Omit, "hash" | "signature">; +/* +export async function resolveTransactionRequest(tx: TransactionRequest, provider?: Provider): Promise { + // A pending transaction with items that may require resolving + const ptx: any = Object.assign({ }, tx); //await resolveProperties(await tx)); + //if (tx.hash != null || tx.signature != null) { + // throw new Error(); + // @TODO: Check for bad keys? + //} + // @TODO: Why does TS not think that to and from are reoslved and require the cast to string + if (ptx.to != null) { ptx.to = resolveAddress((ptx.to), provider); } + if (ptx.from != null) { ptx.from = resolveAddress((ptx.from), provider); } + return await resolveProperties(ptx); +} +*/ + +//function canConnect(value: any): value is T { +// return (value && typeof(value.connect) === "function"); +//} + + +////////////////////// +// Block + +export interface BlockParams { + hash?: null | string; + + number: number; + timestamp: number; + + parentHash: string; + + nonce: string; + difficulty: bigint; + + gasLimit: bigint; + gasUsed: bigint; + + miner: string; + extraData: string; + + baseFeePerGas: null | bigint; + + transactions: ReadonlyArray; +}; + +export interface MinedBlock extends Block { + readonly number: number; + readonly hash: string; + readonly timestamp: number; + readonly date: Date; + readonly miner: string; +} + +export interface LondonBlock extends Block { + readonly baseFeePerGas: bigint; +} + +export class Block implements BlockParams, Iterable { + readonly provider!: Provider; + + readonly number!: number; + readonly hash!: null | string; + readonly timestamp!: number; + + readonly parentHash!: string; + + readonly nonce!: string; + readonly difficulty!: bigint; + + readonly gasLimit!: bigint; + readonly gasUsed!: bigint; + + readonly miner!: string; + readonly extraData!: string; + + readonly baseFeePerGas!: null | bigint; + + readonly #transactions: ReadonlyArray; + + constructor(block: BlockParams, provider?: null | Provider) { + if (provider == null) { provider = dummyProvider; } + + this.#transactions = Object.freeze(block.transactions.map((tx) => { + if (typeof(tx) !== "string" && tx.provider !== provider) { + throw new Error("provider mismatch"); + } + return tx; + }));; + + defineProperties>(this, { + provider, + + hash: getValue(block.hash), + + number: block.number, + timestamp: block.timestamp, + + parentHash: block.parentHash, + + nonce: block.nonce, + difficulty: block.difficulty, + + gasLimit: block.gasLimit, + gasUsed: block.gasUsed, + miner: block.miner, + extraData: block.extraData, + + baseFeePerGas: getValue(block.baseFeePerGas) + }); + } + + get transactions(): ReadonlyArray { return this.#transactions; } + + //connect(provider: Provider): Block { + // return new Block(this, provider); + //} + + toJSON(): any { + const { + baseFeePerGas, difficulty, extraData, gasLimit, gasUsed, hash, + miner, nonce, number, parentHash, timestamp, transactions + } = this; + + return { + _type: "Block", + baseFeePerGas: toJson(baseFeePerGas), + difficulty: toJson(difficulty), + extraData, + gasLimit: toJson(gasLimit), + gasUsed: toJson(gasUsed), + hash, miner, nonce, number, parentHash, timestamp, + transactions, + }; + } + + [Symbol.iterator](): Iterator { + let index = 0; + return { + next: () => { + if (index < this.length) { + return { + value: this.transactions[index++], done: false + } + } + return { value: undefined, done: true }; + } + }; + } + + get length(): number { return this.transactions.length; } + + get date(): null | Date { + if (this.timestamp == null) { return null; } + return new Date(this.timestamp * 1000); + } + + async getTransaction(index: number): Promise { + const tx = this.transactions[index]; + if (tx == null) { throw new Error("no such tx"); } + if (typeof(tx) === "string") { + return (await this.provider.getTransaction(tx)); + } else { + return tx; + } + } + + isMined(): this is MinedBlock { return !!this.hash; } + isLondon(): this is LondonBlock { return !!this.baseFeePerGas; } + + orphanedEvent(): OrphanFilter { + if (!this.isMined()) { throw new Error(""); } + return createOrphanedBlockFilter(this); + } +} + +////////////////////// +// Log + +export interface LogParams { + transactionHash: string; + blockHash: string; + blockNumber: number; + + removed: boolean; + + address: string; + data: string; + + topics: ReadonlyArray; + + index: number; + transactionIndex: number; +} + +export class Log implements LogParams { + readonly provider: Provider; + + readonly transactionHash!: string; + readonly blockHash!: string; + readonly blockNumber!: number; + + readonly removed!: boolean; + + readonly address!: string; + readonly data!: string; + + readonly topics!: ReadonlyArray; + + readonly index!: number; + readonly transactionIndex!: number; + + + constructor(log: LogParams, provider?: null | Provider) { + if (provider == null) { provider = dummyProvider; } + this.provider = provider; + + const topics = Object.freeze(log.topics.slice()); + defineProperties(this, { + transactionHash: log.transactionHash, + blockHash: log.blockHash, + blockNumber: log.blockNumber, + + removed: log.removed, + + address: log.address, + data: log.data, + + topics, + + index: log.index, + transactionIndex: log.transactionIndex, + }); + } + + //connect(provider: Provider): Log { + // return new Log(this, provider); + //} + + toJSON(): any { + const { + address, blockHash, blockNumber, data, index, + removed, topics, transactionHash, transactionIndex + } = this; + + return { + _type: "log", + address, blockHash, blockNumber, data, index, + removed, topics, transactionHash, transactionIndex + }; + } + + async getBlock(): Promise> { + return >(await this.provider.getBlock(this.blockHash)); + } + + async getTransaction(): Promise { + return (await this.provider.getTransaction(this.transactionHash)); + } + + async getTransactionReceipt(): Promise { + return (await this.provider.getTransactionReceipt(this.transactionHash)); + } + + removedEvent(): OrphanFilter { + return createRemovedLogFilter(this); + } +} + + +////////////////////// +// Transaction Receipt + +export interface TransactionReceiptParams { + to: null | string; + from: string; + contractAddress: null | string; + + hash: string; + index: number; + + blockHash: string; + blockNumber: number; + + logsBloom: string; + logs: ReadonlyArray; + + gasUsed: bigint; + cumulativeGasUsed: bigint; + gasPrice?: null | bigint; + effectiveGasPrice?: null | bigint; + + byzantium: boolean; + status: null | number; + root: null | string; +} + +export interface LegacyTransactionReceipt { + byzantium: false; + status: null; + root: string; +} + +export interface ByzantiumTransactionReceipt { + byzantium: true; + status: number; + root: null; +} + +export class TransactionReceipt implements TransactionReceiptParams, Iterable { + readonly provider!: Provider; + + readonly to!: null | string; + readonly from!: string; + readonly contractAddress!: null | string; + + readonly hash!: string; + readonly index!: number; + + readonly blockHash!: string; + readonly blockNumber!: number; + + readonly logsBloom!: string; + + readonly gasUsed!: bigint; + readonly cumulativeGasUsed!: bigint; + readonly gasPrice!: bigint; + + readonly byzantium!: boolean; + readonly status!: null | number; + readonly root!: null | string; + + readonly #logs: ReadonlyArray; + + constructor(tx: TransactionReceiptParams, provider?: null | Provider) { + if (provider == null) { provider = dummyProvider; } + this.#logs = Object.freeze(tx.logs.map((log) => { + if (provider !== log.provider) { + //return log.connect(provider); + throw new Error("provider mismatch"); + } + return log; + })); + + defineProperties(this, { + provider, + + to: tx.to, + from: tx.from, + contractAddress: tx.contractAddress, + + hash: tx.hash, + index: tx.index, + + blockHash: tx.blockHash, + blockNumber: tx.blockNumber, + + logsBloom: tx.logsBloom, + + gasUsed: tx.gasUsed, + cumulativeGasUsed: tx.cumulativeGasUsed, + gasPrice: ((tx.effectiveGasPrice || tx.gasPrice) as bigint), + + byzantium: tx.byzantium, + status: tx.status, + root: tx.root + }); + } + + get logs(): ReadonlyArray { return this.#logs; } + + //connect(provider: Provider): TransactionReceipt { + // return new TransactionReceipt(this, provider); + //} + + toJSON(): any { + const { + to, from, contractAddress, hash, index, blockHash, blockNumber, logsBloom, + logs, byzantium, status, root + } = this; + + return { + _type: "TransactionReceipt", + blockHash, blockNumber, byzantium, contractAddress, + cumulativeGasUsed: toJson(this.cumulativeGasUsed), + from, + gasPrice: toJson(this.gasPrice), + gasUsed: toJson(this.gasUsed), + hash, index, logs, logsBloom, root, status, to + }; + } + + get length(): number { return this.logs.length; } + + [Symbol.iterator](): Iterator { + let index = 0; + return { + next: () => { + if (index < this.length) { + return { value: this.logs[index++], done: false } + } + return { value: undefined, done: true }; + } + }; + } + + get fee(): bigint { + return this.gasUsed * this.gasPrice; + } + + async getBlock(): Promise> { + const block = await this.provider.getBlock(this.blockHash); + if (block == null) { throw new Error("TODO"); } + return block; + } + + async getTransaction(): Promise { + const tx = await this.provider.getTransaction(this.hash); + if (tx == null) { throw new Error("TODO"); } + return tx; + } + + async confirmations(): Promise { + return (await this.provider.getBlockNumber()) - this.blockNumber + 1; + } + + removedEvent(): OrphanFilter { + return createRemovedTransactionFilter(this); + } + + reorderedEvent(other?: TransactionResponse): OrphanFilter { + if (other && !other.isMined()) { + return logger.throwError("unmined 'other' transction cannot be orphaned", "UNSUPPORTED_OPERATION", { + operation: "reorderedEvent(other)" }); + } + return createReorderedTransactionFilter(this, other); + } +} + + +////////////////////// +// Transaction Response + +export interface TransactionResponseParams { + blockNumber: null | number; + blockHash: null | string; + + hash: string; + index: number; + + type: number; + + to: null | string; + from: string; + + nonce: number; + + gasLimit: bigint; + + gasPrice: bigint; + + maxPriorityFeePerGas: null | bigint; + maxFeePerGas: null | bigint; + + data: string; + value: bigint; + chainId: bigint; + + signature: Signature; + + accessList: null | AccessList; +}; + +export interface MinedTransactionResponse extends TransactionResponse { + blockNumber: number; + blockHash: string; + date: Date; +} + +export interface LegacyTransactionResponse extends TransactionResponse { + accessList: null; + maxFeePerGas: null; + maxPriorityFeePerGas: null; +} + +export interface BerlinTransactionResponse extends TransactionResponse { + accessList: AccessList; + maxFeePerGas: null; + maxPriorityFeePerGas: null; +} + +export interface LondonTransactionResponse extends TransactionResponse { + accessList: AccessList; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; +} + +export class TransactionResponse implements TransactionLike, TransactionResponseParams { + readonly provider: Provider; + + readonly blockNumber: null | number; + readonly blockHash: null | string; + + readonly index!: number; + + readonly hash!: string; + + readonly type!: number; + + readonly to!: null | string; + readonly from!: string; + + readonly nonce!: number; + + readonly gasLimit!: bigint; + + readonly gasPrice!: bigint; + + readonly maxPriorityFeePerGas!: null | bigint; + readonly maxFeePerGas!: null | bigint; + + readonly data!: string; + readonly value!: bigint; + readonly chainId!: bigint; + + readonly signature!: Signature; + + readonly accessList!: null | AccessList; + + constructor(tx: TransactionResponseParams, provider?: null | Provider) { + if (provider == null) { provider = dummyProvider; } + this.provider = provider; + + this.blockNumber = (tx.blockNumber != null) ? tx.blockNumber: null; + this.blockHash = (tx.blockHash != null) ? tx.blockHash: null; + + this.hash = tx.hash; + this.index = tx.index; + + this.type = tx.type; + + this.from = tx.from; + this.to = tx.to || null; + + this.gasLimit = tx.gasLimit; + this.nonce = tx.nonce; + this.data = tx.data; + this.value = tx.value; + + this.gasPrice = tx.gasPrice; + this.maxPriorityFeePerGas = (tx.maxPriorityFeePerGas != null) ? tx.maxPriorityFeePerGas: null; + this.maxFeePerGas = (tx.maxFeePerGas != null) ? tx.maxFeePerGas: null; + + this.chainId = tx.chainId; + this.signature = tx.signature; + + this.accessList = (tx.accessList != null) ? tx.accessList: null; + } + + //connect(provider: Provider): TransactionResponse { + // return new TransactionResponse(this, provider); + //} + + toJSON(): any { + const { + blockNumber, blockHash, index, hash, type, to, from, nonce, + data, signature, accessList + } = this; + + return { + _type: "TransactionReceipt", + accessList, blockNumber, blockHash, + chainId: toJson(this.chainId), + data, from, + gasLimit: toJson(this.gasLimit), + gasPrice: toJson(this.gasPrice), + hash, + maxFeePerGas: toJson(this.maxFeePerGas), + maxPriorityFeePerGas: toJson(this.maxPriorityFeePerGas), + nonce, signature, to, index, type, + value: toJson(this.value), + }; + } + + async getBlock(): Promise> { + let blockNumber = this.blockNumber; + if (blockNumber == null) { + const tx = await this.getTransaction(); + if (tx) { blockNumber = tx.blockNumber; } + } + if (blockNumber == null) { return null; } + const block = this.provider.getBlock(blockNumber); + if (block == null) { throw new Error("TODO"); } + return block; + } + + async getTransaction(): Promise { + return this.provider.getTransaction(this.hash); + } + + async wait(confirms?: number): Promise { + return this.provider.waitForTransaction(this.hash, confirms); + } + + isMined(): this is MinedTransactionResponse { + return (this.blockHash != null); + } + + isLegacy(): this is LegacyTransactionResponse { + return (this.type === 0) + } + + isBerlin(): this is BerlinTransactionResponse { + return (this.type === 1); + } + + isLondon(): this is LondonTransactionResponse { + return (this.type === 2); + } + + removedEvent(): OrphanFilter { + if (!this.isMined()) { + return logger.throwError("unmined transaction canot be orphaned", "UNSUPPORTED_OPERATION", { + operation: "removeEvent()" }); + } + return createRemovedTransactionFilter(this); + } + + reorderedEvent(other?: TransactionResponse): OrphanFilter { + if (!this.isMined()) { + return logger.throwError("unmined transaction canot be orphaned", "UNSUPPORTED_OPERATION", { + operation: "removeEvent()" }); + } + if (other && !other.isMined()) { + return logger.throwError("unmined 'other' transaction canot be orphaned", "UNSUPPORTED_OPERATION", { + operation: "removeEvent()" }); + } + return createReorderedTransactionFilter(this, other); + } +} + + +////////////////////// +// OrphanFilter + +export type OrphanFilter = { + orphan: "drop-block", + hash: string, + number: number +} | { + orphan: "drop-transaction", + tx: { hash: string, blockHash: string, blockNumber: number }, + other?: { hash: string, blockHash: string, blockNumber: number } +} | { + orphan: "reorder-transaction", + tx: { hash: string, blockHash: string, blockNumber: number }, + other?: { hash: string, blockHash: string, blockNumber: number } +} | { + orphan: "drop-log", + log: { + transactionHash: string, + blockHash: string, + blockNumber: number, + address: string, + data: string, + topics: ReadonlyArray, + index: number + } +}; + +function createOrphanedBlockFilter(block: { hash: string, number: number }): OrphanFilter { + return { orphan: "drop-block", hash: block.hash, number: block.number }; +} + +function createReorderedTransactionFilter(tx: { hash: string, blockHash: string, blockNumber: number }, other?: { hash: string, blockHash: string, blockNumber: number }): OrphanFilter { + return { orphan: "reorder-transaction", tx, other }; +} + +function createRemovedTransactionFilter(tx: { hash: string, blockHash: string, blockNumber: number }): OrphanFilter { + return { orphan: "drop-transaction", tx }; +} + +function createRemovedLogFilter(log: { blockHash: string, transactionHash: string, blockNumber: number, address: string, data: string, topics: ReadonlyArray, index: number }): OrphanFilter { + return { orphan: "drop-log", log: { + transactionHash: log.transactionHash, + blockHash: log.blockHash, + blockNumber: log.blockNumber, + address: log.address, + data: log.data, + topics: Object.freeze(log.topics.slice()), + index: log.index + } }; +} + +////////////////////// +// EventFilter + +export type TopicFilter = Array>; + +// @TODO: +//export type DeferableTopicFilter = Array | Array>>; + +export interface EventFilter { + address?: AddressLike | Array; + topics?: TopicFilter; +} + +export interface Filter extends EventFilter { + fromBlock?: BlockTag; + toBlock?: BlockTag; +} + +export interface FilterByBlockHash extends EventFilter { + blockHash?: string; +} + + +////////////////////// +// ProviderEvent + +export type ProviderEvent = string | Array> | EventFilter | OrphanFilter; + + +////////////////////// +// Provider + +export interface Provider extends ContractRunner, EventEmitterable, NameResolver { + provider: this; + + + //////////////////// + // State + + /** + * Get the current block number. + */ + getBlockNumber(): Promise; + + /** + * Get the connected [[Network]]. + */ + getNetwork(): Promise>; + + /** + * Get the best guess at the recommended [[FeeData]]. + */ + getFeeData(): Promise; + + + //////////////////// + // Account + + /** + * Get the account balance (in wei) of %%address%%. If %%blockTag%% is specified and + * the node supports archive access, the balance is as of that [[BlockTag]]. + * + * @param {Address | Addressable} address - The account to lookup the balance of + * @param blockTag - The block tag to use for historic archive access. [default: ``"latest"``] + * + * @note On nodes without archive access enabled, the %%blockTag%% may be + * **silently ignored** by the node, which may cause issues if relied on. + */ + getBalance(address: AddressLike, blockTag?: BlockTag): Promise; + + /** + * Get the number of transactions ever sent for %%address%%, which is used as + * the ``nonce`` when sending a transaction. If %%blockTag%% is specified and + * the node supports archive access, the transaction count is as of that [[BlockTag]]. + * + * @param {Address | Addressable} address - The account to lookup the transaction count of + * @param blockTag - The block tag to use for historic archive access. [default: ``"latest"``] + * + * @note On nodes without archive access enabled, the %%blockTag%% may be + * **silently ignored** by the node, which may cause issues if relied on. + */ + getTransactionCount(address: AddressLike, blockTag?: BlockTag): Promise; + + /** + * Get the bytecode for //address//. + * + * @param {Address | Addressable} address - The account to lookup the bytecode of + * @param blockTag - The block tag to use for historic archive access. [default: ``"latest"``] + * + * @note On nodes without archive access enabled, the %%blockTag%% may be + * **silently ignored** by the node, which may cause issues if relied on. + */ + getCode(address: AddressLike, blockTag?: BlockTag): Promise + + /** + * Get the storage slot value for a given //address// and slot //position//. + * + * @param {Address | Addressable} address - The account to lookup the storage of + * @param position - The storage slot to fetch the value of + * @param blockTag - The block tag to use for historic archive access. [default: ``"latest"``] + * + * @note On nodes without archive access enabled, the %%blockTag%% may be + * **silently ignored** by the node, which may cause issues if relied on. + */ + getStorageAt(address: AddressLike, position: BigNumberish, blockTag?: BlockTag): Promise + + + //////////////////// + // Execution + + /** + * Estimates the amount of gas required to executre %%tx%%. + * + * @param tx - The transaction to estimate the gas requirement for + */ + estimateGas(tx: TransactionRequest): Promise; + + // If call fails, throws CALL_EXCEPTION { data: string, error, errorString?, panicReason? } + /** + * Uses call to simulate execution of %%tx%%. + * + * @param tx - The transaction to simulate + */ + call(tx: CallRequest): Promise + + /** + * Broadcasts the %%signedTx%% to the network, adding it to the memory pool + * of any node for which the transaction meets the rebroadcast requirements. + * + * @param signedTx - The transaction to broadcast + */ + broadcastTransaction(signedTx: string): Promise; + + + //////////////////// + // Queries + + getBlock(blockHashOrBlockTag: BlockTag | string): Promise>; + getBlockWithTransactions(blockHashOrBlockTag: BlockTag | string): Promise> + getTransaction(hash: string): Promise; + getTransactionReceipt(hash: string): Promise; + + + //////////////////// + // Bloom-filter Queries + + getLogs(filter: Filter | FilterByBlockHash): Promise>; + + + //////////////////// + // ENS + + resolveName(name: string): Promise; + lookupAddress(address: string): Promise; + + waitForTransaction(hash: string, confirms?: number, timeout?: number): Promise; + waitForBlock(blockTag?: BlockTag): Promise>; +} + + +function fail(): T { + throw new Error("this provider should not be used"); +} + +class DummyProvider implements Provider { + get provider(): this { return this; } + + async getNetwork() { return fail>(); } + async getFeeData() { return fail(); } + + async estimateGas(tx: TransactionRequest) { return fail(); } + async call(tx: CallRequest) { return fail(); } + + async resolveName(name: string) { return fail(); } + + // State + async getBlockNumber() { return fail(); } + + // Account + async getBalance(address: AddressLike, blockTag?: BlockTag) { + return fail(); + } + async getTransactionCount(address: AddressLike, blockTag?: BlockTag) { + return fail(); + } + + async getCode(address: AddressLike, blockTag?: BlockTag) { + return fail(); + } + async getStorageAt(address: AddressLike, position: BigNumberish, blockTag?: BlockTag) { + return fail(); + } + + // Write + async broadcastTransaction(signedTx: string) { return fail(); } + + // Queries + async getBlock(blockHashOrBlockTag: BlockTag | string){ + return fail>(); + } + async getBlockWithTransactions(blockHashOrBlockTag: BlockTag | string) { + return fail>(); + } + async getTransaction(hash: string) { + return fail(); + } + async getTransactionReceipt(hash: string) { + return fail(); + } + + // Bloom-filter Queries + async getLogs(filter: Filter | FilterByBlockHash) { + return fail>(); + } + + // ENS + async lookupAddress(address: string) { + return fail(); + } + + async waitForTransaction(hash: string, confirms?: number, timeout?: number) { + return fail(); + } + + async waitForBlock(blockTag?: BlockTag) { + return fail>(); + } + + // EventEmitterable + async on(event: ProviderEvent, listener: Listener): Promise { return fail(); } + async once(event: ProviderEvent, listener: Listener): Promise { return fail(); } + async emit(event: ProviderEvent, ...args: Array): Promise { return fail(); } + async listenerCount(event?: ProviderEvent): Promise { return fail(); } + async listeners(event?: ProviderEvent): Promise> { return fail(); } + async off(event: ProviderEvent, listener?: Listener): Promise { return fail(); } + async removeAllListeners(event?: ProviderEvent): Promise { return fail(); } + + async addListener(event: ProviderEvent, listener: Listener): Promise { return fail(); } + async removeListener(event: ProviderEvent, listener: Listener): Promise { return fail(); } +} + +/** + * A singleton [[Provider]] instance that can be used as a placeholder. This + * allows API that have a Provider added later to not require a null check. + * + * All operations performed on this [[Provider]] will throw. + */ +export const dummyProvider: Provider = new DummyProvider(); diff --git a/src.ts/providers/signer.ts b/src.ts/providers/signer.ts new file mode 100644 index 000000000..713f45649 --- /dev/null +++ b/src.ts/providers/signer.ts @@ -0,0 +1,149 @@ + +import type { Addressable, NameResolver } from "../address/index.js"; +import type { TypedDataDomain, TypedDataField } from "../hash/index.js"; +import type { TransactionLike } from "../transaction/index.js"; + +import type { ContractRunner } from "./contracts.js"; +import type { BlockTag, CallRequest, Provider, TransactionRequest, TransactionResponse } from "./provider.js"; + +/** + * A Signer represents an account on the Ethereum Blockchain, and is most often + * backed by a private key represented by a mnemonic or residing on a Hardware Wallet. + * + * The API remains abstract though, so that it can deal with more advanced exotic + * Signing entities, such as Smart Contract Wallets or Virtual Wallets (where the + * private key may not be known). + */ +export interface Signer extends Addressable, ContractRunner, NameResolver { + + /** + * The [[Provider]] attached to this Signer (if any). + */ + provider: null | Provider; + + /** + * Returns a new instance of this Signer connected to //provider// or detached + * from any Provider if null. + */ + connect(provider: null | Provider): Signer; + + + //////////////////// + // State + + /** + * Get the [[Address]] of the Signer. + */ + getAddress(): Promise; + + /** + * Gets the next nonce required for this Signer to send a transaction. + * + * @param blockTag - The blocktag to base the transaction count on, keep in mind + * many nodes do not honour this value and silently ignore it [default: ``"latest"``] + */ + getNonce(blockTag?: BlockTag): Promise; + + + //////////////////// + // Preparation + + /** + * Prepares a {@link CallRequest} for calling: + * - resolves ``to`` and ``from`` addresses + * - if ``from`` is specified , check that it matches this Signer + * + * @param tx - The call to prepare + */ + populateCall(tx: CallRequest): Promise>; + + /** + * Prepares a {@link TransactionRequest} for sending to the network by + * populating any missing properties: + * - resolves ``to`` and ``from`` addresses + * - if ``from`` is specified , check that it matches this Signer + * - populates ``nonce`` via ``signer.getNonce("pending")`` + * - populates ``gasLimit`` via ``signer.estimateGas(tx)`` + * - populates ``type`` and relevant fee data for that type (``gasPrice`` + * for legacy transactions, ``maxFeePerGas`` for EIP-1559, etc) + * + * @note Some Signer implementations may skip populating properties that + * are populated downstream; for example JsonRpcSigner defers to the + * node to populate the nonce and fee data. + * + * @param tx - The call to prepare + * @returns The fully prepared {@link TransactionLike} + */ + populateTransaction(tx: TransactionRequest): Promise>; + + + //////////////////// + // Execution + + /** + * Estimates the required gas required to execute //tx// on the Blockchain. This + * will be the expected amount a transaction will require as its ``gasLimit`` + * to successfully run all the necessary computations and store the needed state + * that the transaction intends. + * + * Keep in mind that this is **best efforts**, since the state of the Blockchain + * is in flux, which could affect transaction gas requirements. + * + * @throws UNPREDICTABLE_GAS_LIMIT A transaction that is believed by the node to likely + * fail will throw an error during gas estimation. This could indicate that it + * will actually fail or that the circumstances are simply too complex for the + * node to take into account. In these cases, a manually determined ``gasLimit`` + * will need to be made. + */ + estimateGas(tx: CallRequest): Promise; + + /** + * Evaluates the //tx// by running it against the current Blockchain state. This + * cannot change state and has no cost in ether, as it is effectively simulating + * execution. + * + * This can be used to have the Blockchain perform computations based on its state + * (e.g. running a Contract's getters) or to simulate the effect of a transaction + * before actually performing an operation. + */ + call(tx: CallRequest): Promise; + + /** + * Resolves an [[Address]], ENS Name or [[Addressable]] to an [[Address]]. + */ + resolveName(name: string): Promise; + + + //////////////////// + // Signing + + /** + * Signs %%tx%%, returning the fully signed transaction. This does not populate + * any additional properties within the transaction. + */ + signTransaction(tx: TransactionRequest): Promise; + + /** + * Sends %%tx%% to the Network. The ``signer.populateTransaction(tx)`` is + * called first to ensure all necessary properties for the transaction to be + * valid have been popualted dirst. + */ + sendTransaction(tx: TransactionRequest): Promise; + + /** + * Signers an [[EIP-191]] prefixed personal message. + * + * If the %%message%% is a string, it is signed as UTF-8 encoded bytes. It is **not** + * interpretted as a [[BytesLike]]; so the string ``"0x1234"`` is signed as six + * characters, **not** two bytes. + * + * To sign that example as two bytes, the Uint8Array should be used + * (i.e. ``new Uint8Array([ 0x12, 0x34 ])``). + */ + signMessage(message: string | Uint8Array): Promise; + + /** + * Signs the [[EIP-712]] typed data. + */ + signTypedData(domain: TypedDataDomain, types: Record>, value: Record): Promise; +} diff --git a/src.ts/providers/subscriber-connection.ts b/src.ts/providers/subscriber-connection.ts new file mode 100644 index 000000000..cfaf19144 --- /dev/null +++ b/src.ts/providers/subscriber-connection.ts @@ -0,0 +1,53 @@ + +import type { Subscriber } from "./abstract-provider.js"; +import { logger } from "../utils/logger.js"; + + +//#TODO: Temp +import type { Provider } from "./provider.js"; +export interface ConnectionRpcProvider extends Provider { + //send(method: string, params: Array): Promise; + _subscribe(param: Array, processFunc: (result: any) => void): number; + _unsubscribe(filterId: number): void; +} + +export class BlockConnectionSubscriber implements Subscriber { + #provider: ConnectionRpcProvider; + #blockNumber: number; + + #filterId: null | number; + + constructor(provider: ConnectionRpcProvider) { + this.#provider = provider; + this.#blockNumber = -2; + this.#filterId = null; + } + + start(): void { + this.#filterId = this.#provider._subscribe([ "newHeads" ], (result: any) => { + const blockNumber = logger.getNumber(result.number); + const initial = (this.#blockNumber === -2) ? blockNumber: (this.#blockNumber + 1) + for (let b = initial; b <= blockNumber; b++) { + this.#provider.emit("block", b); + } + this.#blockNumber = blockNumber; + }); + } + + stop(): void { + if (this.#filterId != null) { + this.#provider._unsubscribe(this.#filterId); + this.#filterId = null; + } + } + + pause(dropWhilePaused?: boolean): void { + if (dropWhilePaused) { this.#blockNumber = -2; } + this.stop(); + } + + resume(): void { + this.start(); + } +} + diff --git a/src.ts/providers/subscriber-filterid.ts b/src.ts/providers/subscriber-filterid.ts new file mode 100644 index 000000000..fdd73ecdd --- /dev/null +++ b/src.ts/providers/subscriber-filterid.ts @@ -0,0 +1,133 @@ +import { PollingEventSubscriber } from "./subscriber-polling.js"; + +import type { Frozen } from "../utils/index.js"; + +import type { AbstractProvider, Subscriber } from "./abstract-provider.js"; +import type { Network } from "./network.js"; +import type { EventFilter } from "./provider.js"; +import type { JsonRpcApiProvider } from "./provider-jsonrpc.js"; + + +function copy(obj: any): any { + return JSON.parse(JSON.stringify(obj)); +} + +export class FilterIdSubscriber implements Subscriber { + #provider: JsonRpcApiProvider; + + #filterIdPromise: null | Promise; + #poller: (b: number) => Promise; + + #network: null | Frozen; + + constructor(provider: JsonRpcApiProvider) { + this.#provider = provider; + + this.#filterIdPromise = null; + this.#poller = this.#poll.bind(this); + + this.#network = null; + } + + _subscribe(provider: JsonRpcApiProvider): Promise { + throw new Error("subclasses must override this"); + } + + _emitResults(provider: AbstractProvider, result: Array): Promise { + throw new Error("subclasses must override this"); + } + + _recover(provider: AbstractProvider): Subscriber { + throw new Error("subclasses must override this"); + } + + async #poll(blockNumber: number): Promise { + try { + if (this.#filterIdPromise == null) { + this.#filterIdPromise = this._subscribe(this.#provider); + } + + const filterId = await this.#filterIdPromise; + if (filterId == null) { + this.#provider._recoverSubscriber(this, this._recover(this.#provider)); + return; + } + + const network = await this.#provider.getNetwork(); + if (!this.#network) { this.#network = network; } + + if ((this.#network as Network).chainId !== network.chainId) { + throw new Error("chaid changed"); + } + + const result = await this.#provider.send("eth_getFilterChanges", [ filterId ]); + await this._emitResults(this.#provider, result); + } catch (error) { console.log("@TODO", error); } + + this.#provider.once("block", this.#poller); + } + + #teardown(): void { + const filterIdPromise = this.#filterIdPromise; + if (filterIdPromise) { + this.#filterIdPromise = null; + filterIdPromise.then((filterId) => { + this.#provider.send("eth_uninstallFilter", [ filterId ]); + }); + } + } + + start(): void { this.#poll(-2); } + + stop(): void { + this.#teardown(); + this.#provider.off("block", this.#poller); + } + + pause(dropWhilePaused?: boolean): void { + if (dropWhilePaused){ this.#teardown(); } + this.#provider.off("block", this.#poller); + } + + resume(): void { this.start(); } +} + +export class FilterIdEventSubscriber extends FilterIdSubscriber { + #event: EventFilter; + + constructor(provider: JsonRpcApiProvider, filter: EventFilter) { + super(provider); + this.#event = copy(filter); + } + + _recover(provider: AbstractProvider): Subscriber { + return new PollingEventSubscriber(provider, this.#event); + } + + async _subscribe(provider: JsonRpcApiProvider): Promise { + const filterId = await provider.send("eth_newFilter", [ this.#event ]); + console.log("____SUB", filterId); + return filterId; + } + + async _emitResults(provider: JsonRpcApiProvider, results: Array): Promise { + const network = await provider.getNetwork(); + for (const result of results) { + const log = network.formatter.log(result, provider); + provider.emit(this.#event, log); + } + } +} + +export class FilterIdPendingSubscriber extends FilterIdSubscriber { + async _subscribe(provider: JsonRpcApiProvider): Promise { + return await provider.send("eth_newPendingTransactionFilter", [ ]); + } + + async _emitResults(provider: JsonRpcApiProvider, results: Array): Promise { + const network = await provider.getNetwork(); + for (const result of results) { + provider.emit("pending", network.formatter.hash(result)); + } + } +} diff --git a/src.ts/providers/subscriber-hotswap.ts b/src.ts/providers/subscriber-hotswap.ts new file mode 100644 index 000000000..7a897352d --- /dev/null +++ b/src.ts/providers/subscriber-hotswap.ts @@ -0,0 +1,41 @@ +/* +import { Subscriber } from "./abstract-provider.js"; + +export class HotSwapSubscriber implements Subscriber { + #target?: Subscriber; + + _switchSubscriber(subscriber: Subscriber): void { + this.#target = subscriber; + } + + start(): void { + if (this.#target) { return this.#target.start(); } + return super.start(); + } + + stop(): void { + if (this.#target) { return this.#target.stop(); } + return super.stop(); + } + + pause(dropWhilePaused?: boolean): void { + if (this.#target) { return this.#target.pause(dropWhilePaused); } + return super.pause(dropWhilePaused); + } + + resume(): void { + if (this.#target) { return this.#target.resume(); } + return super.resume(); + } + + set pollingInterval(value: number) { + if (this.#target) { return this.#target.pollingInterval = value; } + return super.pollingInterval = value; + } + + get pollingInterval(): number { + if (this.#target) { return this.#target.pollingInterval; } + return super.pollingInterval; + } +} +*/ diff --git a/src.ts/providers/subscriber-polling.ts b/src.ts/providers/subscriber-polling.ts new file mode 100644 index 000000000..5933f4d61 --- /dev/null +++ b/src.ts/providers/subscriber-polling.ts @@ -0,0 +1,203 @@ +import { isHexString } from "../utils/data.js"; + +import type { AbstractProvider, Subscriber } from "./abstract-provider.js"; +import type { EventFilter, OrphanFilter, ProviderEvent } from "./provider.js"; +import { logger } from "../utils/logger.js"; + +function copy(obj: any): any { + return JSON.parse(JSON.stringify(obj)); +} + +export function getPollingSubscriber(provider: AbstractProvider, event: ProviderEvent): Subscriber { + if (event === "block") { return new PollingBlockSubscriber(provider); } + if (isHexString(event, 32)) { return new PollingTransactionSubscriber(provider, event); } + + return logger.throwError("unsupported polling event", "UNSUPPORTED_OPERATION", { + operation: "getPollingSubscriber", info: { event } + }); +} + +// @TODO: refactor this + +export class PollingBlockSubscriber implements Subscriber{ + #provider: AbstractProvider; + #poller: null | number; + + #interval: number; + + // The most recent block we have scanned for events. The value -2 + // indicates we still need to fetch an initial block number + #blockNumber: number; + + constructor(provider: AbstractProvider) { + this.#provider = provider; + this.#poller = null; + this.#interval = 4000; + + this.#blockNumber = -2; + } + + get pollingInterval(): number { return this.#interval; } + set pollingInterval(value: number) { this.#interval = value; } + + async #poll(): Promise { + const blockNumber = await this.#provider.getBlockNumber(); + if (this.#blockNumber === -2) { + this.#blockNumber = blockNumber; + return; + } + + // @TODO: Put a cap on the maximum number of events per loop? + + if (blockNumber !== this.#blockNumber) { + for (let b = this.#blockNumber + 1; b <= blockNumber; b++) { + this.#provider.emit("block", b); + } + + this.#blockNumber = blockNumber; + } + + this.#poller = this.#provider._setTimeout(this.#poll.bind(this), this.#interval); + } + + start(): void { + if (this.#poller) { throw new Error("subscriber already running"); } + this.#poll(); + this.#poller = this.#provider._setTimeout(this.#poll.bind(this), this.#interval); + } + + stop(): void { + if (!this.#poller) { throw new Error("subscriber not running"); } + this.#provider._clearTimeout(this.#poller); + this.#poller = null; + } + + pause(dropWhilePaused?: boolean): void { + this.stop(); + if (dropWhilePaused) { this.#blockNumber = -2; } + } + + resume(): void { + this.start(); + } +} + +export class OnBlockSubscriber implements Subscriber { + #provider: AbstractProvider; + #poll: (b: number) => void; + + constructor(provider: AbstractProvider) { + this.#provider = provider; + this.#poll = (blockNumber: number) => { + this._poll(blockNumber, this.#provider); + } + } + + async _poll(blockNumber: number, provider: AbstractProvider): Promise { + throw new Error("sub-classes must override this"); + } + + start(): void { + this.#poll(-2); + this.#provider.on("block", this.#poll); + } + + stop(): void { + this.#provider.off("block", this.#poll); + } + + pause(dropWhilePaused?: boolean): void { this.stop(); } + resume(): void { this.start(); } +} + +export class PollingOrphanSubscriber extends OnBlockSubscriber { + #filter: OrphanFilter; + + constructor(provider: AbstractProvider, filter: OrphanFilter) { + super(provider); + this.#filter = copy(filter); + } + + async _poll(blockNumber: number, provider: AbstractProvider): Promise { + throw new Error("@TODO"); + console.log(this.#filter); + } +} + +export class PollingTransactionSubscriber extends OnBlockSubscriber { + #hash: string; + + constructor(provider: AbstractProvider, hash: string) { + super(provider); + this.#hash = hash; + } + + async _poll(blockNumber: number, provider: AbstractProvider): Promise { + const tx = await provider.getTransactionReceipt(this.#hash); + if (tx) { provider.emit(this.#hash, tx); } + } +} + +export class PollingEventSubscriber implements Subscriber { + #provider: AbstractProvider; + #filter: EventFilter; + #poller: (b: number) => void; + + // The most recent block we have scanned for events. The value -2 + // indicates we still need to fetch an initial block number + #blockNumber: number; + + constructor(provider: AbstractProvider, filter: EventFilter) { + this.#provider = provider; + this.#filter = copy(filter); + this.#poller = this.#poll.bind(this); + this.#blockNumber = -2; + } + + async #poll(blockNumber: number): Promise { + // The initial block hasn't been determined yet + if (this.#blockNumber === -2) { return; } + + const filter = copy(this.#filter); + filter.fromBlock = this.#blockNumber + 1; + filter.toBlock = blockNumber; + const logs = await this.#provider.getLogs(filter); + + // No logs could just mean the node has not indexed them yet, + // so we keep a sliding window of 60 blocks to keep scanning + if (logs.length === 0) { + if (this.#blockNumber < blockNumber - 60) { + this.#blockNumber = blockNumber - 60; + } + return; + } + + this.#blockNumber = blockNumber; + + for (const log of logs) { + this.#provider.emit(this.#filter, log); + } + } + + start(): void { + if (this.#blockNumber === -2) { + this.#provider.getBlockNumber().then((blockNumber) => { + this.#blockNumber = blockNumber; + }); + } + this.#provider.on("block", this.#poller); + } + + stop(): void { + this.#provider.off("block", this.#poller); + } + + pause(dropWhilePaused?: boolean): void { + this.stop(); + if (dropWhilePaused) { this.#blockNumber = -2; } + } + + resume(): void { + this.start(); + } +} diff --git a/src.ts/providers/subscriber.ts b/src.ts/providers/subscriber.ts new file mode 100644 index 000000000..6b200387f --- /dev/null +++ b/src.ts/providers/subscriber.ts @@ -0,0 +1,56 @@ + +/* +import { defineProperties } from "@ethersproject/properties"; +export type EventCommon = "block" | "debug" | "blockObject"; + +export type Event = EventCommon | string | { address?: string, topics: Array> } + +export type EventLike = Event | Array; + +export function getTag(eventName: Event): string { + if (typeof(eventName) === "string") { return eventName; } + + if (typeof(eventName) === "object") { + return (eventName.address || "*") + (eventName.topics || []).map((topic) => { + if (typeof(topic) === "string") { return topic; } + return topic.join("|"); + }).join("&"); + } + + throw new Error("FOO"); +} + +export function getEvent(tag: string): Event { +} + +let nextId = 1; + +export class Subscriber { + readonly id!: number; + readonly tag!: string; + + #paused: boolean; + #blockNumber: number; + + constructor(tag: string) { + this.#paused = false; + this.#blockNumber = -1; + defineProperties(this, { id: nextId++, tag }); + } + + get blockNumber(): number { + return this.#blockNumber; + } + _setBlockNumber(blockNumber: number): void { this.#blockNumber = blockNumber; } + + setup(): void { } + teardown(): void { } + + isPaused(): boolean { return this.#paused; } + pause(): void { this.#paused = true; } + resume(): void { this.#paused = false; } + + resubscribeInfo(): string { return this.tag; } + resubscribe(info: string): boolean { return true; } +} +*/ diff --git a/src.ts/providers/ws-browser.ts b/src.ts/providers/ws-browser.ts new file mode 100644 index 000000000..3e9281f4b --- /dev/null +++ b/src.ts/providers/ws-browser.ts @@ -0,0 +1,11 @@ + +function getGlobal(): any { + if (typeof self !== 'undefined') { return self; } + if (typeof window !== 'undefined') { return window; } + if (typeof global !== 'undefined') { return global; } + throw new Error('unable to locate global object'); +}; + +const _WebSocket = getGlobal().WebSocket; + +export { _WebSocket as WebSocket }; diff --git a/src.ts/providers/ws.ts b/src.ts/providers/ws.ts new file mode 100644 index 000000000..09d86da2f --- /dev/null +++ b/src.ts/providers/ws.ts @@ -0,0 +1,3 @@ +export { WebSocket } from "ws"; + + diff --git a/src.ts/thirdparty.d.ts b/src.ts/thirdparty.d.ts new file mode 100644 index 000000000..f35673325 --- /dev/null +++ b/src.ts/thirdparty.d.ts @@ -0,0 +1,16 @@ + + +declare module "ws" { + export class WebSocket { + constructor(...args: Array); + + onopen: null | ((...args: Array) => any); + onmessage: null | ((...args: Array) => any); + onerror: null | ((...args: Array) => any); + + readyState: number; + + send(payload: any): void; + close(code?: number, reason?: string): void; + } +} diff --git a/src.ts/transaction/accesslist.ts b/src.ts/transaction/accesslist.ts new file mode 100644 index 000000000..4d68d389f --- /dev/null +++ b/src.ts/transaction/accesslist.ts @@ -0,0 +1,43 @@ +import { getAddress } from "../address/index.js"; +import { dataLength } from "../utils/index.js"; + +import type { AccessList, AccessListish } from "./index.js"; + + +function accessSetify(addr: string, storageKeys: Array): { address: string,storageKeys: Array } { + return { + address: getAddress(addr), + storageKeys: (storageKeys || []).map((storageKey, index) => { + if (dataLength(storageKey) !== 32) { + //logger.throwArgumentError("invalid access list storageKey", `accessList[${ addr }> + throw new Error(""); + } + return storageKey.toLowerCase(); + }) + }; +} + +export function accessListify(value: AccessListish): AccessList { + if (Array.isArray(value)) { + return (] | { address: string, storageKeys: Array}>>value).map((set, index) => { + if (Array.isArray(set)) { + if (set.length > 2) { + //logger.throwArgumentError("access list expected to be [ address, storageKeys[> + throw new Error(""); + } + return accessSetify(set[0], set[1]) + } + return accessSetify(set.address, set.storageKeys); + }); + } + + const result: Array<{ address: string, storageKeys: Array }> = Object.keys(value).map((addr) => { + const storageKeys: Record = value[addr].reduce((accum, storageKey) => { + accum[storageKey] = true; + return accum; + }, >{ }); + return accessSetify(addr, Object.keys(storageKeys).sort()) + }); + result.sort((a, b) => (a.address.localeCompare(b.address))); + return result; +} diff --git a/src.ts/transaction/address.ts b/src.ts/transaction/address.ts new file mode 100644 index 000000000..06c78c2c0 --- /dev/null +++ b/src.ts/transaction/address.ts @@ -0,0 +1,15 @@ +import { getAddress } from "../address/index.js"; +import { keccak256, SigningKey } from "../crypto/index.js"; + +import type { SignatureLike } from "../crypto/index.js"; +import type { BytesLike } from "../utils/index.js"; + + +export function computeAddress(key: string): string { + const publicKey = SigningKey.computePublicKey(key, false); + return getAddress(keccak256("0x" + publicKey.substring(4)).substring(26)); +} + +export function recoverAddress(digest: BytesLike, signature: SignatureLike): string { + return computeAddress(SigningKey.recoverPublicKey(digest, signature)); +} diff --git a/src.ts/transaction/index.ts b/src.ts/transaction/index.ts new file mode 100644 index 000000000..a0b7ad454 --- /dev/null +++ b/src.ts/transaction/index.ts @@ -0,0 +1,15 @@ + +export type AccessListSet = { address: string, storageKeys: Array }; +export type AccessList = Array; + +// Input allows flexibility in describing an access list +export type AccessListish = AccessList | + Array<[ string, Array ]> | + Record>; + + +export { accessListify } from "./accesslist.js"; +export { computeAddress, recoverAddress } from "./address.js"; +export { Transaction } from "./transaction.js"; + +export type { SignedTransaction, TransactionLike } from "./transaction.js"; diff --git a/src.ts/transaction/transaction.ts b/src.ts/transaction/transaction.ts new file mode 100644 index 000000000..896321086 --- /dev/null +++ b/src.ts/transaction/transaction.ts @@ -0,0 +1,673 @@ + +import { getAddress } from "../address/index.js"; +import { keccak256, Signature } from "../crypto/index.js"; +import { + concat, decodeRlp, encodeRlp, getStore, hexlify, logger, setStore, toArray, zeroPadValue +} from "../utils/index.js"; + +import { accessListify } from "./accesslist.js"; +import { recoverAddress } from "./address.js"; + +import type { BigNumberish, BytesLike, Freezable, Frozen } from "../utils/index.js"; +import type { SignatureLike } from "../crypto/index.js"; + +import type { AccessList, AccessListish } from "./index.js"; + + +const BN_0 = BigInt(0); +const BN_2 = BigInt(2); +const BN_27 = BigInt(27) +const BN_28 = BigInt(28) +const BN_35 = BigInt(35); +const BN_MAX_UINT = BigInt("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + +export interface TransactionLike { + type?: null | number; + + to?: null | A; + from?: null | A; + + nonce?: null | number; + + gasLimit?: null | BigNumberish; + gasPrice?: null | BigNumberish; + + maxPriorityFeePerGas?: null | BigNumberish; + maxFeePerGas?: null | BigNumberish; + + data?: null | string; + value?: null | BigNumberish; + chainId?: null | BigNumberish; + + hash?: null | string; + + signature?: null | SignatureLike; + + accessList?: null | AccessListish; +} + +function handleAddress(value: string): null | string { + if (value === "0x") { return null; } + return getAddress(value); +} + +function handleData(value: string, param: string): string { + try { + return hexlify(value); + } catch (error) { + return logger.throwArgumentError("invalid data", param, value); + } +} + +function handleAccessList(value: any, param: string): AccessList { + try { + return accessListify(value); + } catch (error) { + return logger.throwArgumentError("invalid accessList", param, value); + } +} + +function handleNumber(_value: string, param: string): number { + if (_value === "0x") { return 0; } + return logger.getNumber(_value, param); +} + +function handleUint(_value: string, param: string): bigint { + if (_value === "0x") { return BN_0; } + const value = logger.getBigInt(_value, param); + if (value > BN_MAX_UINT) { logger.throwArgumentError("value exceeds uint size", param, value); } + return value; +} + +function formatNumber(_value: BigNumberish, name: string): Uint8Array { + const value = logger.getBigInt(_value, "value"); + const result = toArray(value); + if (result.length > 32) { + logger.throwArgumentError(`value too large`, `tx.${ name }`, value); + } + return result; +} + +function formatAccessList(value: AccessListish): Array<[ string, Array ]> { + return accessListify(value).map((set) => [ set.address, set.storageKeys ]); +} + +function _parseLegacy(data: Uint8Array): TransactionLike { + const fields: any = decodeRlp(data); + + if (!Array.isArray(fields) || (fields.length !== 9 && fields.length !== 6)) { + return logger.throwArgumentError("invalid field count for legacy transaction", "data", data); + } + + const tx: TransactionLike = { + type: 0, + nonce: handleNumber(fields[0], "nonce"), + gasPrice: handleUint(fields[1], "gasPrice"), + gasLimit: handleUint(fields[2], "gasLimit"), + to: handleAddress(fields[3]), + value: handleUint(fields[4], "value"), + data: handleData(fields[5], "dta"), + chainId: BN_0 + }; + + // Legacy unsigned transaction + if (fields.length === 6) { return tx; } + + const v = handleUint(fields[6], "v"); + const r = handleUint(fields[7], "r"); + const s = handleUint(fields[8], "s"); + + if (r === BN_0 && s === BN_0) { + // EIP-155 unsigned transaction + tx.chainId = v; + + } else { + + // Compute the EIP-155 chain ID (or 0 for legacy) + let chainId = (v - BN_35) / BN_2; + if (chainId < BN_0) { chainId = BN_0; } + tx.chainId = chainId + + // Signed Legacy Transaction + if (chainId === BN_0 && (v < BN_27 || v > BN_28)) { + logger.throwArgumentError("non-canonical legacy v", "v", fields[6]); + } + + tx.signature = Signature.from({ + r: zeroPadValue(fields[7], 32), + s: zeroPadValue(fields[8], 32), + v + }); + + tx.hash = keccak256(data); + } + + return tx; +} + +function _serializeLegacy(tx: Transaction, sig?: Signature): string { + const fields: Array = [ + formatNumber(tx.nonce || 0, "nonce"), + formatNumber(tx.gasPrice || 0, "gasPrice"), + formatNumber(tx.gasLimit || 0, "gasLimit"), + ((tx.to != null) ? getAddress(tx.to): "0x"), + formatNumber(tx.value || 0, "value"), + (tx.data || "0x"), + ]; + + let chainId = BN_0; + if (tx.chainId != null) { + // A chainId was provided; if non-zero we'll use EIP-155 + chainId = logger.getBigInt(tx.chainId, "tx.chainId"); + + // We have a chainId in the tx and an EIP-155 v in the signature, + // make sure they agree with each other + if (sig && sig.networkV != null && sig.legacyChainId !== chainId) { + logger.throwArgumentError("tx.chainId/sig.v mismatch", "sig", sig); + } + + } else if (sig) { + // No chainId provided, but the signature is signing with EIP-155; derive chainId + const legacy = sig.legacyChainId; + if (legacy != null) { chainId = legacy; } + } + + // Requesting an unsigned transaction + if (!sig) { + // We have an EIP-155 transaction (chainId was specified and non-zero) + if (chainId !== BN_0) { + fields.push(toArray(chainId)); + fields.push("0x"); + fields.push("0x"); + } + + return encodeRlp(fields); + } + + // We pushed a chainId and null r, s on for hashing only; remove those + let v = BigInt(27 + sig.yParity); + if (chainId !== BN_0) { + v = Signature.getChainIdV(chainId, sig.v); + } else if (BigInt(sig.v) !== v) { + logger.throwArgumentError("tx.chainId/sig.v mismatch", "sig", sig); + } + + fields.push(toArray(v)); + fields.push(toArray(sig.r)); + fields.push(toArray(sig.s)); + + return encodeRlp(fields); +} + +function _parseEipSignature(tx: TransactionLike, fields: Array, serialize: (tx: TransactionLike) => string): void { + let yParity: number; + try { + yParity = handleNumber(fields[0], "yParity"); + if (yParity !== 0 && yParity !== 1) { throw new Error("bad yParity"); } + } catch (error) { + return logger.throwArgumentError("invalid yParity", "yParity", fields[0]); + } + + const r = zeroPadValue(fields[1], 32); + const s = zeroPadValue(fields[2], 32); + + const signature = Signature.from({ r, s, yParity }); + tx.signature = signature; +} + +function _parseEip1559(data: Uint8Array): TransactionLike { + const fields: any = decodeRlp(logger.getBytes(data).slice(1)); + + if (!Array.isArray(fields) || (fields.length !== 9 && fields.length !== 12)) { + logger.throwArgumentError("invalid field count for transaction type: 2", "data", hexlify(data)); + } + + const maxPriorityFeePerGas = handleUint(fields[2], "maxPriorityFeePerGas"); + const maxFeePerGas = handleUint(fields[3], "maxFeePerGas"); + const tx: TransactionLike = { + type: 2, + chainId: handleUint(fields[0], "chainId"), + nonce: handleNumber(fields[1], "nonce"), + maxPriorityFeePerGas: maxPriorityFeePerGas, + maxFeePerGas: maxFeePerGas, + gasPrice: null, + gasLimit: handleUint(fields[4], "gasLimit"), + to: handleAddress(fields[5]), + value: handleUint(fields[6], "value"), + data: handleData(fields[7], "data"), + accessList: handleAccessList(fields[8], "accessList"), + }; + + // Unsigned EIP-1559 Transaction + if (fields.length === 9) { return tx; } + + tx.hash = keccak256(data); + + _parseEipSignature(tx, fields.slice(9), _serializeEip1559); + + return tx; +} + +function _serializeEip1559(tx: TransactionLike, sig?: Signature): string { + const fields: Array = [ + formatNumber(tx.chainId || 0, "chainId"), + formatNumber(tx.nonce || 0, "nonce"), + formatNumber(tx.maxPriorityFeePerGas || 0, "maxPriorityFeePerGas"), + formatNumber(tx.maxFeePerGas || 0, "maxFeePerGas"), + formatNumber(tx.gasLimit || 0, "gasLimit"), + ((tx.to != null) ? getAddress(tx.to): "0x"), + formatNumber(tx.value || 0, "value"), + (tx.data || "0x"), + (formatAccessList(tx.accessList || [])) + ]; + + if (sig) { + fields.push(formatNumber(sig.yParity, "yParity")); + fields.push(toArray(sig.r)); + fields.push(toArray(sig.s)); + } + + return concat([ "0x02", encodeRlp(fields)]); +} + +function _parseEip2930(data: Uint8Array): TransactionLike { + const fields: any = decodeRlp(logger.getBytes(data).slice(1)); + + if (!Array.isArray(fields) || (fields.length !== 8 && fields.length !== 11)) { + logger.throwArgumentError("invalid field count for transaction type: 1", "data", hexlify(data)); + } + + const tx: TransactionLike = { + type: 1, + chainId: handleUint(fields[0], "chainId"), + nonce: handleNumber(fields[1], "nonce"), + gasPrice: handleUint(fields[2], "gasPrice"), + gasLimit: handleUint(fields[3], "gasLimit"), + to: handleAddress(fields[4]), + value: handleUint(fields[5], "value"), + data: handleData(fields[6], "data"), + accessList: handleAccessList(fields[7], "accessList") + }; + + // Unsigned EIP-2930 Transaction + if (fields.length === 8) { return tx; } + + tx.hash = keccak256(data); + + _parseEipSignature(tx, fields.slice(8), _serializeEip2930); + + return tx; +} + +function _serializeEip2930(tx: TransactionLike, sig?: Signature): string { + const fields: any = [ + formatNumber(tx.chainId || 0, "chainId"), + formatNumber(tx.nonce || 0, "nonce"), + formatNumber(tx.gasPrice || 0, "gasPrice"), + formatNumber(tx.gasLimit || 0, "gasLimit"), + ((tx.to != null) ? getAddress(tx.to): "0x"), + formatNumber(tx.value || 0, "value"), + (tx.data || "0x"), + (formatAccessList(tx.accessList || [])) + ]; + + if (sig) { + fields.push(formatNumber(sig.yParity, "recoveryParam")); + fields.push(toArray(sig.r)); + fields.push(toArray(sig.s)); + } + + return concat([ "0x01", encodeRlp(fields)]); +} + +export interface SignedTransaction extends Transaction { + type: number; + typeName: string; + from: string; + signature: Signature; +} + +export interface LegacyTransaction extends Transaction { + type: 0; + gasPrice: bigint; +} + +export interface BerlinTransaction extends Transaction { + type: 1; + gasPrice: bigint; + accessList: AccessList; +} + +export interface LondonTransaction extends Transaction { + type: 2; + maxFeePerGas: bigint; + maxPriorityFeePerGas: bigint; + accessList: AccessList; +} + +export class Transaction implements Freezable, TransactionLike { + #props: { + type: null | number, + to: null | string, + data: string, + nonce: number, + gasLimit: bigint, + gasPrice: null | bigint, + maxPriorityFeePerGas: null | bigint, + maxFeePerGas: null | bigint, + value: bigint, + chainId: bigint, + sig: null | Signature, + accessList: null | AccessList + }; + + // A type of null indicates the type will be populated automatically + get type(): null | number { return getStore(this.#props, "type"); } + get typeName(): null | string { + switch (this.type) { + case 0: return "legacy"; + case 1: return "eip-2930"; + case 2: return "eip-1559"; + } + + return null; + } + set type(value: null | number | string) { + switch (value) { + case null: + setStore(this.#props, "type", null); + break; + case 0: case "legacy": + setStore(this.#props, "type", 0); + break; + case 1: case "berlin": case "eip-2930": + setStore(this.#props, "type", 1); + break; + case 2: case "london": case "eip-1559": + setStore(this.#props, "type", 2); + break; + default: + throw new Error(`unsupported transaction type`); + } + } + + get to(): null | string { return getStore(this.#props, "to"); } + set to(value: null | string) { + setStore(this.#props, "to", (value == null) ? null: getAddress(value)); + } + + get nonce(): number { return getStore(this.#props, "nonce"); } + set nonce(value: BigNumberish) { setStore(this.#props, "nonce", logger.getNumber(value, "value")); } + + get gasLimit(): bigint { return getStore(this.#props, "gasLimit"); } + set gasLimit(value: BigNumberish) { setStore(this.#props, "gasLimit", logger.getBigInt(value)); } + + get gasPrice(): null | bigint { + const value = getStore(this.#props, "gasPrice"); + if (value == null && (this.type === 0 || this.type === 1)) { return BN_0; } + return value; + } + set gasPrice(value: null | BigNumberish) { + setStore(this.#props, "gasPrice", (value == null) ? null: logger.getBigInt(value, "gasPrice")); + } + + get maxPriorityFeePerGas(): null | bigint { + const value = getStore(this.#props, "maxPriorityFeePerGas"); + if (value == null && this.type === 2) { return BN_0; } + return value; + } + set maxPriorityFeePerGas(value: null | BigNumberish) { + setStore(this.#props, "maxPriorityFeePerGas", (value == null) ? null: logger.getBigInt(value, "maxPriorityFeePerGas")); + } + + get maxFeePerGas(): null | bigint { + const value = getStore(this.#props, "maxFeePerGas"); + if (value == null && this.type === 2) { return BN_0; } + return value; + } + set maxFeePerGas(value: null | BigNumberish) { + setStore(this.#props, "maxFeePerGas", (value == null) ? null: logger.getBigInt(value, "maxFeePerGas")); + } + + get data(): string { return getStore(this.#props, "data"); } + set data(value: BytesLike) { setStore(this.#props, "data", hexlify(value)); } + + get value(): bigint { return getStore(this.#props, "value"); } + set value(value: BigNumberish) { + setStore(this.#props, "value", logger.getBigInt(value, "value")); + } + + get chainId(): bigint { return getStore(this.#props, "chainId"); } + set chainId(value: BigNumberish) { setStore(this.#props, "chainId", logger.getBigInt(value)); } + + get signature(): null | Signature { return getStore(this.#props, "sig") || null; } + set signature(value: null | SignatureLike) { + setStore(this.#props, "sig", (value == null) ? null: Signature.from(value)); + } + + get accessList(): null | AccessList { + const value = getStore(this.#props, "accessList") || null; + if (value == null && (this.type === 1 || this.type === 2)) { return [ ]; } + return value; + } + set accessList(value: null | AccessListish) { + setStore(this.#props, "accessList", (value == null) ? null: accessListify(value)); + } + + constructor() { + this.#props = { + type: null, + to: null, + nonce: 0, + gasLimit: BigInt(0), + gasPrice: null, + maxPriorityFeePerGas: null, + maxFeePerGas: null, + data: "0x", + value: BigInt(0), + chainId: BigInt(0), + sig: null, + accessList: null + }; + } + + get hash(): null | string { + if (this.signature == null) { + throw new Error("cannot hash unsigned transaction; maybe you meant .unsignedHash"); + } + return keccak256(this.serialized); + } + + get unsignedHash(): string { + return keccak256(this.unsignedSerialized); + } + + get from(): null | string { + if (this.signature == null) { return null; } + return recoverAddress(this.unsignedSerialized, this.signature); + } + + get fromPublicKey(): null | string { + if (this.signature == null) { return null; } + // use ecrecover + return ""; + } + + isSigned(): this is SignedTransaction { + return this.signature != null; + } + + get serialized(): string { + if (this.signature == null) { + throw new Error("cannot serialize unsigned transaction; maybe you meant .unsignedSerialized"); + } + + const types = this.inferTypes(); + if (types.length !== 1) { + throw new Error("cannot determine transaction type; specify type manually"); + } + + switch (types[0]) { + case 0: + return _serializeLegacy(this, this.signature); + case 1: + return _serializeEip2930(this, this.signature); + case 2: + return _serializeEip1559(this, this.signature); + } + + throw new Error("unsupported type"); + } + + get unsignedSerialized(): string { + const types = this.inferTypes(); + if (types.length !== 1) { + throw new Error("cannot determine transaction type; specify type manually"); + } + + switch (types[0]) { + case 0: + return _serializeLegacy(this); + case 1: + return _serializeEip2930(this); + case 2: + return _serializeEip1559(this); + } + + throw new Error("unsupported type"); + } + + // Validates properties and lists possible types this transaction adheres to + inferTypes(): Array { + + // Checks that there are no conflicting properties set + const hasGasPrice = this.gasPrice != null; + const hasFee = (this.maxFeePerGas != null || this.maxPriorityFeePerGas != null); + const hasAccessList = (this.accessList != null); + + //if (hasGasPrice && hasFee) { + // throw new Error("transaction cannot have gasPrice and maxFeePerGas"); + //} + + if (this.maxFeePerGas != null && this.maxPriorityFeePerGas != null) { + if (this.maxFeePerGas < this.maxPriorityFeePerGas) { + throw new Error("priorityFee cannot be more than maxFee"); + } + } + + //if (this.type === 2 && hasGasPrice) { + // throw new Error("eip-1559 transaction cannot have gasPrice"); + //} + + if ((this.type === 0 || this.type === 1) && hasFee) { + throw new Error("transaction type cannot have maxFeePerGas or maxPriorityFeePerGas"); + } + + if (this.type === 0 && hasAccessList) { + throw new Error("legacy transaction cannot have accessList"); + } + + const types: Array = [ ]; + + // Explicit type + if (this.type != null) { + types.push(this.type); + + } else { + if (hasFee) { + types.push(2); + } else if (hasGasPrice) { + types.push(1); + if (!hasAccessList) { types.push(0); } + } else if (hasAccessList) { + types.push(1); + types.push(2); + } else { + types.push(0); + types.push(1); + types.push(2); + } + } + + types.sort(); + + return types; + } + + isLegacy(): this is LegacyTransaction { return (this.type === 0); } + isBerlin(): this is BerlinTransaction { return (this.type === 1); } + isLondon(): this is LondonTransaction { return (this.type === 2); } + + clone(): Transaction { + return Transaction.from(this); + } + + freeze(): Frozen { + if (this.#props.sig) { + this.#props.sig = (this.#props.sig.clone().freeze()); + } + + if (this.#props.accessList) { + this.#props.accessList = Object.freeze(this.#props.accessList.map((set) => { + Object.freeze(set.storageKeys); + return Object.freeze(set); + })); + } + + Object.freeze(this.#props); + return this; + } + + isFrozen(): boolean { + return Object.isFrozen(this.#props); + } + + static from(tx: string | TransactionLike): Transaction { + if (typeof(tx) === "string") { + const payload = logger.getBytes(tx); + + if (payload[0] >= 0x7f) { // @TODO: > vs >= ?? + return Transaction.from(_parseLegacy(payload)); + } + + switch(payload[0]) { + case 1: return Transaction.from(_parseEip2930(payload)); + case 2: return Transaction.from(_parseEip1559(payload)); + } + + throw new Error("unsupported transaction type"); + } + + const result = new Transaction(); + if (tx.type != null) { result.type = tx.type; } + if (tx.to != null) { result.to = tx.to; } + if (tx.nonce != null) { result.nonce = tx.nonce; } + if (tx.gasLimit != null) { result.gasLimit = tx.gasLimit; } + if (tx.gasPrice != null) { result.gasPrice = tx.gasPrice; } + if (tx.maxPriorityFeePerGas != null) { result.maxPriorityFeePerGas = tx.maxPriorityFeePerGas; } + if (tx.maxFeePerGas != null) { result.maxFeePerGas = tx.maxFeePerGas; } + if (tx.data != null) { result.data = tx.data; } + if (tx.value != null) { result.value = tx.value; } + if (tx.chainId != null) { result.chainId = tx.chainId; } + if (tx.signature != null) { result.signature = Signature.from(tx.signature); } + if (tx.accessList != null) { result.accessList = tx.accessList; } + + if (tx.hash != null) { + if (result.isSigned()) { + if (result.hash !== tx.hash) { throw new Error("hash mismatch"); } + } else { + throw new Error("unsigned transaction cannot have a hashs"); + } + } + + if (tx.from != null) { + if (result.isSigned()) { + if (result.from.toLowerCase() !== (tx.from || "").toLowerCase()) { throw new Error("from mismatch"); } + } else { + throw new Error("unsigned transaction cannot have a from"); + } + } + + return result; + } +} diff --git a/src.ts/utils/base58.ts b/src.ts/utils/base58.ts new file mode 100644 index 000000000..067385eaa --- /dev/null +++ b/src.ts/utils/base58.ts @@ -0,0 +1,52 @@ + +import { logger } from "./logger.js"; +import { toBigInt, toHex } from "./maths.js"; + +import type { BytesLike } from "./index.js"; + + +const Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +let Lookup: null | Record = null; + +function getAlpha(letter: string): bigint { + if (Lookup == null) { + Lookup = { }; + for (let i = 0; i < Alphabet.length; i++) { + Lookup[Alphabet[i]] = BigInt(i); + } + } + const result = Lookup[letter]; + if (result == null) { + logger.throwArgumentError(`invalid base58 value`, "letter", letter); + } + return result; +} + + +const BN_0 = BigInt(0); +const BN_58 = BigInt(58); + +/** + * Encode %%value%% as Base58-encoded data. + */ +export function encodeBase58(_value: BytesLike): string { + let value = toBigInt(logger.getBytes(_value)); + let result = ""; + while (value) { + result = Alphabet[Number(value % BN_58)] + result; + value /= BN_58; + } + return result; +} + +/** + * Decode the Base58-encoded %%value%%. + */ +export function decodeBase58(value: string): string { + let result = BN_0; + for (let i = 0; i < value.length; i++) { + result *= BN_58; + result += getAlpha(value[i]); + } + return toHex(result); +} diff --git a/src.ts/utils/base64-browser.ts b/src.ts/utils/base64-browser.ts new file mode 100644 index 000000000..b620b8441 --- /dev/null +++ b/src.ts/utils/base64-browser.ts @@ -0,0 +1,25 @@ + +// utils/base64-browser + +import { logger } from "./logger.js"; + +import type { BytesLike } from "./data.js"; + + +export function decodeBase64(textData: string): Uint8Array { + textData = atob(textData); + const data = new Uint8Array(textData.length); + for (let i = 0; i < textData.length; i++) { + data[i] = textData.charCodeAt(i); + } + return logger.getBytes(data); +} + +export function encodeBase64(_data: BytesLike): string { + const data = logger.getBytes(_data); + let textData = ""; + for (let i = 0; i < data.length; i++) { + textData += String.fromCharCode(data[i]); + } + return btoa(textData); +} diff --git a/src.ts/utils/base64.ts b/src.ts/utils/base64.ts new file mode 100644 index 000000000..96fff281c --- /dev/null +++ b/src.ts/utils/base64.ts @@ -0,0 +1,12 @@ +import { logger } from "./logger.js"; + +import type { BytesLike } from "./data.js"; + + +export function decodeBase64(textData: string): Uint8Array { + return logger.getBytesCopy(Buffer.from(textData, "base64")); +}; + +export function encodeBase64(data: BytesLike): string { + return Buffer.from(logger.getBytes(data)).toString("base64"); +} diff --git a/src.ts/utils/data.ts b/src.ts/utils/data.ts new file mode 100644 index 000000000..dce7d7334 --- /dev/null +++ b/src.ts/utils/data.ts @@ -0,0 +1,84 @@ +import { logger } from "./logger.js"; + +export type BytesLike = string | Uint8Array; + + +export function isHexString(value: any, length?: number | boolean): value is string { + if (typeof(value) !== "string" || !value.match(/^0x[0-9A-Fa-f]*$/)) { + return false + } + + if (typeof(length) === "number" && value.length !== 2 + 2 * length) { return false; } + if (length === true && (value.length % 2) !== 0) { return false; } + + return true; +} + +export function isBytesLike(value: any): value is BytesLike { + return (isHexString(value, true) || (value instanceof Uint8Array)); +} + +const HexCharacters: string = "0123456789abcdef"; +export function hexlify(data: BytesLike): string { + const bytes = logger.getBytes(data); + + let result = "0x"; + for (let i = 0; i < bytes.length; i++) { + const v = bytes[i]; + result += HexCharacters[(v & 0xf0) >> 4] + HexCharacters[v & 0x0f]; + } + return result; +} + +export function concat(datas: ReadonlyArray): string { + return "0x" + datas.map((d) => hexlify(d).substring(2)).join(""); +} + +export function dataLength(data: BytesLike): number { + if (isHexString(data, true)) { return (data.length - 2) / 2; } + return logger.getBytes(data).length; +} + +export function dataSlice(data: BytesLike, start?: number, end?: number): string { + const bytes = logger.getBytes(data); + if (end != null && end > bytes.length) { logger.throwError("cannot slice beyond data bounds", "BUFFER_OVERRUN", { + buffer: bytes, length: bytes.length, offset: end + }); } + return hexlify(bytes.slice((start == null) ? 0: start, (end == null) ? bytes.length: end)); +} + +export function stripZerosLeft(data: BytesLike): string { + let bytes = hexlify(data).substring(2); + while (bytes.substring(0, 2) == "00") { bytes = bytes.substring(2); } + return "0x" + bytes; +} + + +function zeroPad(data: BytesLike, length: number, left: boolean): string { + const bytes = logger.getBytes(data); + if (length < bytes.length) { + logger.throwError("padding exceeds data length", "BUFFER_OVERRUN", { + buffer: new Uint8Array(bytes), + length: length, + offset: length + 1 + }); + } + + const result = new Uint8Array(length); + result.fill(0); + if (left) { + result.set(bytes, length - bytes.length); + } else { + result.set(bytes, 0); + } + + return hexlify(result); +} + +export function zeroPadValue(data: BytesLike, length: number): string { + return zeroPad(data, length, true); +} + +export function zeroPadBytes(data: BytesLike, length: number): string { + return zeroPad(data, length, false); +} diff --git a/src.ts/utils/errors.ts b/src.ts/utils/errors.ts new file mode 100644 index 000000000..824c046ae --- /dev/null +++ b/src.ts/utils/errors.ts @@ -0,0 +1,285 @@ +//export type TransactionReceipt { +//} + +export type ErrorSignature = { + r: string; + s: string; + yParity: 0 | 1; + networkV: bigint; +}; + +export type ErrorAccessList = Array<{ address: string, storageKeys: Array }>; + +/* +export interface ErrorTransaction { + type?: number; + + to?: string; + from?: string; + + nonce?: number; + + gasLimit?: bigint; + gasPrice?: bigint; + + maxPriorityFeePerGas?: bigint; + maxFeePerGas?: bigint; + + data?: string; + value?: bigint; + chainId?: bigint; + + hash?: string; + + signature?: ErrorSignature; + + accessList?: ErrorAccessList; +} +*/ + +export interface ErrorFetchRequestWithBody extends ErrorFetchRequest { + body: Readonly; +} + +export interface ErrorFetchRequest { + url: string; + method: string; + headers: Readonly>; + getHeader(key: string): string; + body: null | Readonly; + hasBody(): this is ErrorFetchRequestWithBody; +} + + +export interface ErrorFetchResponseWithBody extends ErrorFetchResponse { + body: Readonly; +} + +export interface ErrorFetchResponse { + statusCode: number; + statusMessage: string; + headers: Readonly>; + getHeader(key: string): string; + body: null | Readonly; + hasBody(): this is ErrorFetchResponseWithBody; +} + + +export type ErrorCode = + + // Generic Errors + "UNKNOWN_ERROR" | "NOT_IMPLEMENTED" | "UNSUPPORTED_OPERATION" | + "NETWORK_ERROR" | "SERVER_ERROR" | "TIMEOUT" | "BAD_DATA" | + "CANCELLED" | + + // Operational Errors + "BUFFER_OVERRUN" | "NUMERIC_FAULT" | + + // Argument Errors + "INVALID_ARGUMENT" | "MISSING_ARGUMENT" | "UNEXPECTED_ARGUMENT" | + "VALUE_MISMATCH" | + + // Blockchain Errors + "CALL_EXCEPTION" | "INSUFFICIENT_FUNDS" | "NONCE_EXPIRED" | + "REPLACEMENT_UNDERPRICED" | "TRANSACTION_REPLACED" | + "UNPREDICTABLE_GAS_LIMIT" | + "UNCONFIGURED_NAME" | "OFFCHAIN_FAULT" | + + // User Interaction + "ACTION_REJECTED" +; + +export interface EthersError extends Error { + code: ErrorCode; +// recover?: (...args: Array) => any; + info?: Record; + error?: Error; +} + +// Generic Errors + +export interface UnknownError extends EthersError<"UNKNOWN_ERROR"> { + [ key: string ]: any; +} + +export interface NotImplementedError extends EthersError<"NOT_IMPLEMENTED"> { + operation: string; +} + +export interface UnsupportedOperationError extends EthersError<"UNSUPPORTED_OPERATION"> { + operation: string; +} + +export interface NetworkError extends EthersError<"NETWORK_ERROR"> { + event: string; +} + +export interface ServerError extends EthersError<"SERVER_ERROR"> { + request: ErrorFetchRequest | string; + response?: ErrorFetchResponse; +} + +export interface TimeoutError extends EthersError<"TIMEOUT"> { + operation: string; + reason: string; + request?: ErrorFetchRequest; +} + +export interface BadDataError extends EthersError<"BAD_DATA"> { + value: any; +} + +export interface CancelledError extends EthersError<"CANCELLED"> { +} + + +// Operational Errors + +export interface BufferOverrunError extends EthersError<"BUFFER_OVERRUN"> { + buffer: Uint8Array; + length: number; + offset: number; +} + +export interface NumericFaultError extends EthersError<"NUMERIC_FAULT"> { + operation: string; + fault: string; + value: any; +} + + +// Argument Errors + +export interface InvalidArgumentError extends EthersError<"INVALID_ARGUMENT"> { + argument: string; + value: any; + info?: Record +} + +export interface MissingArgumentError extends EthersError<"MISSING_ARGUMENT"> { + count: number; + expectedCount: number; +} + +export interface UnexpectedArgumentError extends EthersError<"UNEXPECTED_ARGUMENT"> { + count: number; + expectedCount: number; +} + +//export interface ValueMismatchError extends EthersError { +// count: number; +// expectedCount: number; +//} + + +// Blockchain Errors + +export interface CallExceptionError extends EthersError<"CALL_EXCEPTION"> { + // The revert data + data: string; + + // The transaction that triggered the exception + transaction?: any; + + // The Contract, method and args used during invocation + method?: string; + signature?: string; + args?: ReadonlyArray; + + // The Solidity custom revert error + errorSignature?: string; + errorName?: string; + errorArgs?: ReadonlyArray; + reason?: string; +} + +//export interface ContractCallExceptionError extends CallExceptionError { + // The transaction call +// transaction: any;//ErrorTransaction; +//} + +export interface InsufficientFundsError extends EthersError<"INSUFFICIENT_FUNDS"> { + transaction: any;//ErrorTransaction; +} + +export interface NonceExpiredError extends EthersError<"NONCE_EXPIRED"> { + transaction: any; //ErrorTransaction; +} + +export interface OffchainFaultError extends EthersError<"OFFCHAIN_FAULT"> { + transaction?: any; + reason: string; +} + +export interface ReplacementUnderpricedError extends EthersError<"REPLACEMENT_UNDERPRICED"> { + transaction: any; //ErrorTransaction; +} + +export interface TransactionReplacedError extends EthersError<"TRANSACTION_REPLACED"> { + cancelled: boolean; + reason: "repriced" | "cancelled" | "replaced"; + hash: string; + replacement: any; //TransactionResponse; + receipt: any; //TransactionReceipt; +} + +export interface UnconfiguredNameError extends EthersError<"UNCONFIGURED_NAME"> { + value: string; +} + +export interface UnpredictableGasLimitError extends EthersError<"UNPREDICTABLE_GAS_LIMIT"> { + transaction: any; //ErrorTransaction; +} + +export interface ActionRejectedError extends EthersError<"ACTION_REJECTED"> { + action: string +} + +// Coding; converts an ErrorCode its Typed Error + +export type CodedEthersError = + T extends "UNKNOWN_ERROR" ? UnknownError: + T extends "NOT_IMPLEMENTED" ? NotImplementedError: + T extends "UNSUPPORTED_OPERATION" ? UnsupportedOperationError: + T extends "NETWORK_ERROR" ? NetworkError: + T extends "SERVER_ERROR" ? ServerError: + T extends "TIMEOUT" ? TimeoutError: + T extends "BAD_DATA" ? BadDataError: + T extends "CANCELLED" ? CancelledError: + + T extends "BUFFER_OVERRUN" ? BufferOverrunError: + T extends "NUMERIC_FAULT" ? NumericFaultError: + + T extends "INVALID_ARGUMENT" ? InvalidArgumentError: + T extends "MISSING_ARGUMENT" ? MissingArgumentError: + T extends "UNEXPECTED_ARGUMENT" ? UnexpectedArgumentError: + + T extends "CALL_EXCEPTION" ? CallExceptionError: + T extends "INSUFFICIENT_FUNDS" ? InsufficientFundsError: + T extends "NONCE_EXPIRED" ? NonceExpiredError: + T extends "OFFCHAIN_FAULT" ? OffchainFaultError: + T extends "REPLACEMENT_UNDERPRICED" ? ReplacementUnderpricedError: + T extends "TRANSACTION_REPLACED" ? TransactionReplacedError: + T extends "UNCONFIGURED_NAME" ? UnconfiguredNameError: + T extends "UNPREDICTABLE_GAS_LIMIT" ? UnpredictableGasLimitError: + + T extends "ACTION_REJECTED" ? ActionRejectedError: + + never; + +/** + * try { + * // code.... + * } catch (e) { + * if (isError(e, errors.CALL_EXCEPTION)) { + * console.log(e.data); + * } + * } + */ +export function isError>(error: any, code: K): error is T { + return (error && (error).code === code); +} + +export function isCallException(error: any): error is CallExceptionError { + return isError(error, "CALL_EXCEPTION"); +} diff --git a/src.ts/utils/events.ts b/src.ts/utils/events.ts new file mode 100644 index 000000000..6c2a57e9f --- /dev/null +++ b/src.ts/utils/events.ts @@ -0,0 +1,36 @@ +import { defineProperties } from "./properties.js"; + +export type Listener = (...args: Array) => void; + +export interface EventEmitterable { + on(event: T, listener: Listener): Promise; + once(event: T, listener: Listener): Promise; + emit(event: T, ...args: Array): Promise; + listenerCount(event?: T): Promise; + listeners(event?: T): Promise>; + off(event: T, listener?: Listener): Promise; + removeAllListeners(event?: T): Promise; + + // Alias for "on" + addListener(event: T, listener: Listener): Promise; + + // Alias for "off" + removeListener(event: T, listener: Listener): Promise; +} + +export class EventPayload { + readonly filter!: T; + + readonly emitter!: EventEmitterable; + readonly #listener: null | Listener; + + constructor(emitter: EventEmitterable, listener: null | Listener, filter: T) { + this.#listener = listener; + defineProperties>(this, { emitter, filter }); + } + + async removeListener(): Promise { + if (this.#listener == null) { return; } + await this.emitter.off(this.filter, this.#listener); + } +} diff --git a/src.ts/utils/fetch.ts b/src.ts/utils/fetch.ts new file mode 100644 index 000000000..b0c45bcc6 --- /dev/null +++ b/src.ts/utils/fetch.ts @@ -0,0 +1,657 @@ +import { decodeBase64, encodeBase64 } from "./base64.js"; +import { hexlify } from "./data.js"; +import { assertArgument, logger } from "./logger.js"; +import { defineProperties } from "./properties.js"; +import { toUtf8Bytes, toUtf8String } from "./utf8.js" + +import { getUrl } from "./geturl.js"; + + +export type GetUrlResponse = { + statusCode: number, + statusMessage: string, + headers: Record, + body: null | Uint8Array +}; + +export interface FetchRequestWithBody extends FetchRequest { + body: Uint8Array; +} + +/** + * Called before any network request, allowing updated headers (e.g. Bearer tokens), etc. + */ +export type FetchPreflightFunc = (request: FetchRequest) => Promise; + +/** + * Called on the response, allowing client-based throttling logic or post-processing. + */ +export type FetchProcessFunc = (request: FetchRequest, response: FetchResponse) => Promise; + +/** + * Called prior to each retry; return true to retry, false to abort. + */ +export type FetchRetryFunc = (request: FetchRequest, response: FetchResponse, attempt: number) => Promise; + +/** + * Called on Gateway URLs. + */ +export type FetchGatewayFunc = (url: string, signal?: FetchCancelSignal) => Promise; + +/** + * Used to perform a fetch; use this to override the underlying network + * fetch layer. In NodeJS, the default uses the "http" and "https" libraries + * and in the browser ``fetch`` is used. If you wish to use Axios, this is + * how you would register it. + */ +export type FetchGetUrlFunc = (request: FetchRequest, signal?: FetchCancelSignal) => Promise; + + +const MAX_ATTEMPTS = 12; +const SLOT_INTERVAL = 250; + +// The global FetchGetUrlFunc implementation. +let getUrlFunc: FetchGetUrlFunc = getUrl; + +const reData = new RegExp("^data:([^;:]*)?(;base64)?,(.*)$", "i"); +const reIpfs = new RegExp("^ipfs:/\/(ipfs/)?(.*)$", "i"); + +// If locked, new Gateways cannot be added +let locked = false; + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs +async function gatewayData(url: string, signal?: FetchCancelSignal): Promise { + try { + const match = url.match(reData); + if (!match) { throw new Error("invalid data"); } + return new FetchResponse(200, "OK", { + "content-type": (match[1] || "text/plain"), + }, (match[1] ? decodeBase64(match[3]): unpercent(match[3]))); + } catch (error) { + return new FetchResponse(599, "BAD REQUEST (invalid data: URI)", { }, null, new FetchRequest(url)); + } +} + +export function getIpfsGatewayFunc(base: string): FetchGatewayFunc { + async function gatewayIpfs(url: string, signal?: FetchCancelSignal): Promise { + try { + const match = url.match(reIpfs); + if (!match) { throw new Error("invalid link"); } + return new FetchRequest(`${ base }${ match[2] }`); + } catch (error) { + return new FetchResponse(599, "BAD REQUEST (invalid IPFS URI)", { }, null, new FetchRequest(url)); + } + } + + return gatewayIpfs; +} + +const Gateways: Record = { + "data": gatewayData, + "ipfs": getIpfsGatewayFunc("https:/\/gateway.ipfs.io/ipfs/") +}; + +const fetchSignals: WeakMap void> = new WeakMap(); + +export class FetchCancelSignal { + #listeners: Array<() => void>; + #cancelled: boolean; + + constructor(request: FetchRequest) { + this.#listeners = [ ]; + this.#cancelled = false; + + fetchSignals.set(request, () => { + if (this.#cancelled) { return; } + this.#cancelled = true; + + for (const listener of this.#listeners) { + setTimeout(() => { listener(); }, 0); + } + this.#listeners = [ ]; + }); + } + + addListener(listener: () => void): void { + if (this.#cancelled) { + logger.throwError("singal already cancelled", "UNSUPPORTED_OPERATION", { + operation: "fetchCancelSignal.addCancelListener" + }); + } + this.#listeners.push(listener); + } + + get cancelled(): boolean { return this.cancelled; } + + checkSignal(): void { + if (!this.cancelled) { return; } + logger.throwError("cancelled", "CANCELLED", { }); + } +} + +// Check the signal, throwing if it is cancelled +function checkSignal(signal?: FetchCancelSignal): FetchCancelSignal { + if (signal == null) { throw new Error("missing signal; should not happen"); } + signal.checkSignal(); + return signal; +} + +export class FetchRequest implements Iterable<[ key: string, value: string ]> { + #allowInsecure: boolean; + #gzip: boolean; + #headers: Record; + #method: string; + #timeout: number; + #url: string; + + #body?: Uint8Array; + #bodyType?: string; + #creds?: string; + + // Hooks + #preflight?: null | FetchPreflightFunc; + #process?: null | FetchProcessFunc; + #retry?: null | FetchRetryFunc; + + #signal?: FetchCancelSignal; + + // URL + get url(): string { return this.#url; } + set url(url: string) { + this.#url = String(url); + } + + // Body + get body(): null | Uint8Array { + if (this.#body == null) { return null; } + return new Uint8Array(this.#body); + } + set body(body: null | string | Readonly | Readonly) { + if (body == null) { + this.#body = undefined; + this.#bodyType = undefined; + } else if (typeof(body) === "string") { + this.#body = toUtf8Bytes(body); + this.#bodyType = "text/plain"; + } else if (body instanceof Uint8Array) { + this.#body = body; + this.#bodyType = "application/octet-stream"; + } else if (typeof(body) === "object") { + this.#body = toUtf8Bytes(JSON.stringify(body)); + this.#bodyType = "application/json"; + } else { + throw new Error("invalid body"); + } + } + + hasBody(): this is FetchRequestWithBody { + return (this.#body != null); + } + + // Method (default: GET with no body, POST with a body) + get method(): string { + if (this.#method) { return this.#method; } + if (this.hasBody()) { return "POST"; } + return "GET"; + } + set method(method: null | string) { + if (method == null) { method = ""; } + this.#method = String(method).toUpperCase(); + } + + // Headers (automatically fills content-type if not explicitly set) + get headers(): Readonly> { + const headers = Object.assign({ }, this.#headers); + + if (this.#creds) { + headers["authorization"] = `Basic ${ encodeBase64(toUtf8Bytes(this.#creds)) }`; + }; + + if (this.allowGzip) { + headers["accept-encoding"] = "gzip"; + } + + if (headers["content-type"] == null && this.#bodyType) { + headers["content-type"] = this.#bodyType; + } + if (this.body) { headers["content-length"] = String(this.body.length); } + + return Object.freeze(headers); + } + getHeader(key: string): string { + return this.headers[key.toLowerCase()]; + } + setHeader(key: string, value: string | number): void { + this.#headers[String(key).toLowerCase()] = String(value); + } + clearHeaders(): void { + this.#headers = { }; + } + + [Symbol.iterator](): Iterator<[ key: string, value: string ]> { + const headers = this.headers; + const keys = Object.keys(headers); + let index = 0; + return { + next: () => { + if (index < keys.length) { + const key = keys[index++]; + return { + value: [ key, headers[key] ], done: false + } + } + return { value: undefined, done: true }; + } + }; + } + + // Configure an Authorization header + get credentials(): null | string { + return this.#creds || null; + } + setCredentials(username: string, password: string): void { + if (username.match(/:/)) { + logger.throwArgumentError("invalid basic authentication username", "username", "[REDACTED]"); + } + this.#creds = `${ username }:${ password }`; + } + + // Configure the request to allow gzipped responses + get allowGzip(): boolean { + return this.#gzip; + } + set allowGzip(value: boolean) { + this.#gzip = !!value; + } + + // Allow credentials to be sent over an insecure (non-HTTPS) channel + get allowInsecureAuthentication(): boolean { + return !!this.#allowInsecure; + } + set allowInsecureAuthentication(value: boolean) { + this.#allowInsecure = !!value; + } + + // Timeout (milliseconds) + get timeout(): number { return this.#timeout; } + set timeout(timeout: number) { + assertArgument(timeout >= 0, "timeout must be non-zero", "timeout", timeout); + this.#timeout = timeout; + } + + // Preflight called before each request is sent + get preflightFunc(): null | FetchPreflightFunc { + return this.#preflight || null; + } + set preflightFunc(preflight: null | FetchPreflightFunc) { + this.#preflight = preflight; + } + + // Preflight called before each request is sent + get processFunc(): null | FetchProcessFunc { + return this.#process || null; + } + set processFunc(process: null | FetchProcessFunc) { + this.#process = process; + } + + // Preflight called before each request is sent + get retryFunc(): null | FetchRetryFunc { + return this.#retry || null; + } + set retryFunc(retry: null | FetchRetryFunc) { + this.#retry = retry; + } + + constructor(url: string) { + this.#url = String(url); + + this.#allowInsecure = false; + this.#gzip = false; + this.#headers = { }; + this.#method = ""; + this.#timeout = 300; + } + + async #send(attempt: number, expires: number, delay: number, _request: FetchRequest, _response: FetchResponse): Promise { + if (attempt >= MAX_ATTEMPTS) { + return _response.makeServerError("exceeded maximum retry limit"); + } + + if (getTime() > expires) { + return logger.throwError("timeout", "TIMEOUT", { + operation: "request.send", reason: "timeout", request: _request + }); + } + + if (delay > 0) { await wait(delay); } + + let req = this.clone(); + const scheme = (req.url.split(":")[0] || "").toLowerCase(); + + // Process any Gateways + if (scheme in Gateways) { + const result = await Gateways[scheme](req.url, checkSignal(_request.#signal)); + if (result instanceof FetchResponse) { + let response = result; + + if (this.processFunc) { + checkSignal(_request.#signal); + try { + response = await this.processFunc(req, response); + } catch (error: any) { + + // Something went wrong during processing; throw a 5xx server error + if (error.throttle == null || typeof(error.stall) !== "number") { + response.makeServerError("error in post-processing function", error).assertOk(); + } + + // Ignore throttling + } + } + + return response; + } + req = result; + } + + // We have a preflight function; update the request + if (this.preflightFunc) { req = await this.preflightFunc(req); } + + const resp = await getUrlFunc(req, checkSignal(_request.#signal)); + let response = new FetchResponse(resp.statusCode, resp.statusMessage, resp.headers, resp.body, _request); + + if (response.statusCode === 301 || response.statusCode === 302) { + + // Redirect + try { + const location = response.headers.location || ""; + return req.redirect(location).#send(attempt + 1, expires, 0, _request, response); + } catch (error) { } + + // Things won't get any better on another attempt; abort + return response; + + } else if (response.statusCode === 429) { + + // Throttle + if (this.retryFunc == null || (await this.retryFunc(req, response, attempt))) { + const retryAfter = response.headers["retry-after"]; + let delay = SLOT_INTERVAL * Math.trunc(Math.random() * Math.pow(2, attempt)); + if (typeof(retryAfter) === "string" && retryAfter.match(/^[1-9][0-9]*$/)) { + delay = parseInt(retryAfter); + } + return req.clone().#send(attempt + 1, expires, delay, _request, response); + } + } + + if (this.processFunc) { + checkSignal(_request.#signal); + try { + response = await this.processFunc(req, response); + } catch (error: any) { + + // Something went wrong during processing; throw a 5xx server error + if (error.throttle == null || typeof(error.stall) !== "number") { + response.makeServerError("error in post-processing function", error).assertOk(); + } + + // Throttle + let delay = SLOT_INTERVAL * Math.trunc(Math.random() * Math.pow(2, attempt));; + if (error.stall >= 0) { delay = error.stall; } + + return req.clone().#send(attempt + 1, expires, delay, _request, response); + } + } + + return response; + } + + send(): Promise { + if (this.#signal != null) { + return logger.throwError("request already sent", "UNSUPPORTED_OPERATION", { operation: "fetchRequest.send" }); + } + this.#signal = new FetchCancelSignal(this); + return this.#send(0, getTime() + this.timeout, 0, this, new FetchResponse(0, "", { }, null, this)); + } + + cancel(): void { + if (this.#signal == null) { + return logger.throwError("request has not been sent", "UNSUPPORTED_OPERATION", { operation: "fetchRequest.cancel" }); + } + const signal = fetchSignals.get(this); + if (!signal) { throw new Error("missing signal; should not happen"); } + signal(); + } + + /** + * Returns a new [[FetchRequest]] that represents the redirection + * to %%location%%. + */ + redirect(location: string): FetchRequest { + // Redirection; for now we only support absolute locataions + const current = this.url.split(":")[0].toLowerCase(); + const target = location.split(":")[0].toLowerCase(); + + // Don't allow redirecting: + // - non-GET requests + // - downgrading the security (e.g. https => http) + // - to non-HTTP (or non-HTTPS) protocols [this could be relaxed?] + if (this.method !== "GET" || (current === "https" && target === "http") || !location.match(/^https?:/)) { + return logger.throwError(`unsupported redirect`, "UNSUPPORTED_OPERATION", { + operation: `redirect(${ this.method } ${ JSON.stringify(this.url) } => ${ JSON.stringify(location) })` + }); + } + + // Create a copy of this request, with a new URL + const req = new FetchRequest(location); + req.method = "GET"; + req.allowGzip = this.allowGzip; + req.timeout = this.timeout; + req.#headers = Object.assign({ }, this.#headers); + if (this.#body) { req.#body = new Uint8Array(this.#body); } + req.#bodyType = this.#bodyType; + + // Do not forward credentials unless on the same domain; only absolute + //req.allowInsecure = false; + // paths are currently supported; may want a way to specify to forward? + //setStore(req.#props, "creds", getStore(this.#pros, "creds")); + + return req; + } + + clone(): FetchRequest { + const clone = new FetchRequest(this.url); + + // Preserve "default method" (i.e. null) + clone.#method = this.#method; + + // Preserve "default body" with type, copying the Uint8Array is present + if (this.#body) { clone.#body = this.#body; } + clone.#bodyType = this.#bodyType; + + // Preserve "default headers" + clone.#headers = Object.assign({ }, this.#headers); + + // Credentials is readonly, so we copy internally + clone.#creds = this.#creds; + + if (this.allowGzip) { clone.allowGzip = true; } + + clone.timeout = this.timeout; + if (this.allowInsecureAuthentication) { clone.allowInsecureAuthentication = true; } + + clone.#preflight = this.#preflight; + clone.#process = this.#process; + clone.#retry = this.#retry; + + return clone; + } + + static lockConfig(): void { + locked = true; + } + + static getGateway(scheme: string): null | FetchGatewayFunc { + return Gateways[scheme.toLowerCase()] || null; + } + + static registerGateway(scheme: string, func: FetchGatewayFunc): void { + scheme = scheme.toLowerCase(); + if (scheme === "http" || scheme === "https") { + throw new Error(`cannot intercept ${ scheme }; use registerGetUrl`); + } + if (locked) { throw new Error("gateways locked"); } + Gateways[scheme] = func; + } + + static registerGetUrl(getUrl: FetchGetUrlFunc): void { + if (locked) { throw new Error("gateways locked"); } + getUrlFunc = getUrl; + } +} + + +export interface FetchResponseWithBody extends FetchResponse { + body: Readonly; +} + +interface ThrottleError extends Error { + stall: number; + throttle: true; +}; + +export class FetchResponse implements Iterable<[ key: string, value: string ]> { + #statusCode: number; + #statusMessage: string; + #headers: Readonly>; + #body: null | Readonly; + #request: null | FetchRequest; + + #error: { error?: Error, message: string }; + + toString(): string { + return ``; + } + + get statusCode(): number { return this.#statusCode; } + get statusMessage(): string { return this.#statusMessage; } + get headers() { return this.#headers; } + get body(): null | Readonly { + return (this.#body == null) ? null: new Uint8Array(this.#body); + } + get bodyText(): string { + try { + return (this.#body == null) ? "": toUtf8String(this.#body); + } catch (error) { + return logger.throwError("response body is not valid UTF-8 data", "UNSUPPORTED_OPERATION", { + operation: "bodyText", info: { response: this } + }); + } + } + get bodyJson(): any { + try { + return JSON.parse(this.bodyText); + } catch (error) { + return logger.throwError("response body is not valid JSON", "UNSUPPORTED_OPERATION", { + operation: "bodyJson", info: { response: this } + }); + } + } + + [Symbol.iterator](): Iterator<[ key: string, value: string ]> { + const headers = this.headers; + const keys = Object.keys(headers); + let index = 0; + return { + next: () => { + if (index < keys.length) { + const key = keys[index++]; + return { + value: [ key, headers[key] ], done: false + } + } + return { value: undefined, done: true }; + } + }; + } + + constructor(statusCode: number, statusMessage: string, headers: Readonly>, body: null | Uint8Array, request?: FetchRequest) { + this.#statusCode = statusCode; + this.#statusMessage = statusMessage; + this.#headers = Object.freeze(Object.assign({ }, Object.keys(headers).reduce((accum, k) => { + accum[k.toLowerCase()] = String(headers[k]); + return accum; + }, >{ }))); + this.#body = ((body == null) ? null: new Uint8Array(body)); + this.#request = (request || null); + + this.#error = { message: "" }; + } + + makeServerError(message?: string, error?: Error): FetchResponse { + let statusMessage: string; + if (!message) { + message = `${ this.statusCode } ${ this.statusMessage }`; + statusMessage = `CLIENT ESCALATED SERVER ERROR (${ message })`; + } else { + statusMessage = `CLIENT ESCALATED SERVER ERROR (${ this.statusCode } ${ this.statusMessage }; ${ message })`; + } + const response = new FetchResponse(599, statusMessage, this.headers, + this.body, this.#request || undefined); + response.#error = { message, error }; + return response; + } + + throwThrottleError(message?: string, stall?: number): never { + if (stall == null) { + stall = -1; + } else if (typeof(stall) !== "number" || !Number.isInteger(stall) || stall < 0) { + return logger.throwArgumentError("invalid stall timeout", "stall", stall); + } + + const error = new Error(message || "throttling requests"); + + defineProperties(error, { stall, throttle: true }); + + throw error; + } + + getHeader(key: string): string { + return this.headers[key.toLowerCase()]; + } + + hasBody(): this is FetchResponseWithBody { + return (this.#body != null); + } + + get request(): null | FetchRequest { return this.#request; } + + ok(): boolean { + return (this.#error.message === "" && this.statusCode >= 200 && this.statusCode < 300); + } + + assertOk(): void { + if (this.ok()) { return; } + let { message, error } = this.#error; + if (message === "") { + message = `server response ${ this.statusCode } ${ this.statusMessage }`; + } + logger.throwError(message, "SERVER_ERROR", { + request: (this.request || "unknown request"), response: this, error + }); + } +} + + +function getTime(): number { return (new Date()).getTime(); } + +function unpercent(value: string): Uint8Array { + return toUtf8Bytes(value.replace(/%([0-9a-f][0-9a-f])/gi, (all, code) => { + return String.fromCharCode(parseInt(code, 16)); + })); +} + +function wait(delay: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delay)); +} diff --git a/src.ts/utils/fixednumber.ts b/src.ts/utils/fixednumber.ts new file mode 100644 index 000000000..ee3c24571 --- /dev/null +++ b/src.ts/utils/fixednumber.ts @@ -0,0 +1,396 @@ +import { logger } from "./logger.js"; +import { fromTwos, toBigInt, toHex, toTwos } from "./maths.js"; + +import type { BigNumberish, BytesLike, Numeric } from "./index.js"; + + +const _constructorGuard = { }; + +const NegativeOne = BigInt(-1); + +function throwFault(message: string, fault: string, operation: string, value?: any): never { + const params: any = { fault: fault, operation: operation }; + if (value !== undefined) { params.value = value; } + return logger.throwError(message, "NUMERIC_FAULT", params); +} + +// Constant to pull zeros from for multipliers +let zeros = "0"; +while (zeros.length < 256) { zeros += zeros; } + +// Returns a string "1" followed by decimal "0"s +function getMultiplier(decimals: number): bigint { + + if (typeof(decimals) !== "number" || decimals < 0 || decimals > 256 || decimals % 1 ) { + logger.throwArgumentError("invalid decimal length", "decimals", decimals); + } + + return BigInt("1" + zeros.substring(0, decimals)); +} + +export function formatFixed(_value: BigNumberish, _decimals?: Numeric): string { + if (_decimals == null) { _decimals = 18; } + + let value = logger.getBigInt(_value, "value"); + const decimals = logger.getNumber(_decimals, "decimals"); + + const multiplier = getMultiplier(decimals); + const multiplierStr = String(multiplier); + + const negative = (value < 0); + if (negative) { value *= NegativeOne; } + + let fraction = String(value % multiplier); + + // Make sure there are enough place-holders + while (fraction.length < multiplierStr.length - 1) { fraction = "0" + fraction; } + + // Strip training 0 + while (fraction.length > 1 && fraction.substring(fraction.length - 1) === "0") { + fraction = fraction.substring(0, fraction.length - 1); + } + + let result = String(value / multiplier); + if (multiplierStr.length !== 1) { result += "." + fraction; } + + if (negative) { result = "-" + result; } + + return result; +} + +export function parseFixed(value: string, _decimals: Numeric): bigint { + if (_decimals == null) { _decimals = 18; } + const decimals = logger.getNumber(_decimals, "decimals"); + + const multiplier = getMultiplier(decimals); + + if (typeof(value) !== "string" || !value.match(/^-?[0-9.]+$/)) { + logger.throwArgumentError("invalid decimal value", "value", value); + } + + // Is it negative? + const negative = (value.substring(0, 1) === "-"); + if (negative) { value = value.substring(1); } + + if (value === ".") { + logger.throwArgumentError("missing value", "value", value); + } + + // Split it into a whole and fractional part + const comps = value.split("."); + if (comps.length > 2) { + logger.throwArgumentError("too many decimal points", "value", value); + } + + let whole = (comps[0] || "0"), fraction = (comps[1] || "0"); + + // Trim trialing zeros + while (fraction[fraction.length - 1] === "0") { + fraction = fraction.substring(0, fraction.length - 1); + } + + // Check the fraction doesn't exceed our decimals size + if (fraction.length > String(multiplier).length - 1) { + throwFault("fractional component exceeds decimals", "underflow", "parseFixed"); + } + + // If decimals is 0, we have an empty string for fraction + if (fraction === "") { fraction = "0"; } + + // Fully pad the string with zeros to get to wei + while (fraction.length < String(multiplier).length - 1) { fraction += "0"; } + + const wholeValue = BigInt(whole); + const fractionValue = BigInt(fraction); + + let wei = (wholeValue * multiplier) + fractionValue; + + if (negative) { wei *= NegativeOne; } + + return wei; +} + + +export class FixedFormat { + readonly signed: boolean; + readonly width: number; + readonly decimals: number; + readonly name: string; + + readonly _multiplier: bigint; + + constructor(constructorGuard: any, signed: boolean, width: number, decimals: number) { + if (constructorGuard !== _constructorGuard) { + logger.throwError("cannot use FixedFormat constructor; use FixedFormat.from", "UNSUPPORTED_OPERATION", { + operation: "new FixedFormat" + }); + } + + this.signed = signed; + this.width = width; + this.decimals = decimals; + + this.name = (signed ? "": "u") + "fixed" + String(width) + "x" + String(decimals); + + this._multiplier = getMultiplier(decimals); + + Object.freeze(this); + } + + static from(value: any): FixedFormat { + if (value instanceof FixedFormat) { return value; } + + if (typeof(value) === "number") { + value = `fixed128x${value}` + } + + let signed = true; + let width = 128; + let decimals = 18; + + if (typeof(value) === "string") { + if (value === "fixed") { + // defaults... + } else if (value === "ufixed") { + signed = false; + } else { + const match = value.match(/^(u?)fixed([0-9]+)x([0-9]+)$/); + if (!match) { + return logger.throwArgumentError("invalid fixed format", "format", value); + } + signed = (match[1] !== "u"); + width = parseInt(match[2]); + decimals = parseInt(match[3]); + } + } else if (value) { + const check = (key: string, type: string, defaultValue: any): any => { + if (value[key] == null) { return defaultValue; } + if (typeof(value[key]) !== type) { + logger.throwArgumentError("invalid fixed format (" + key + " not " + type +")", "format." + key, value[key]); + } + return value[key]; + } + signed = check("signed", "boolean", signed); + width = check("width", "number", width); + decimals = check("decimals", "number", decimals); + } + + if (width % 8) { + logger.throwArgumentError("invalid fixed format width (not byte aligned)", "format.width", width); + } + + if (decimals > 80) { + logger.throwArgumentError("invalid fixed format (decimals too large)", "format.decimals", decimals); + } + + return new FixedFormat(_constructorGuard, signed, width, decimals); + } +} + +export class FixedNumber { + readonly format: FixedFormat; + + readonly _isFixedNumber: boolean; + + //#hex: string; + #value: string; + + constructor(constructorGuard: any, hex: string, value: string, format?: FixedFormat) { + if (constructorGuard !== _constructorGuard) { + logger.throwError("cannot use FixedNumber constructor; use FixedNumber.from", "UNSUPPORTED_OPERATION", { + operation: "new FixedFormat" + }); + } + + this.format = FixedFormat.from(format); + //this.#hex = hex; + this.#value = value; + + this._isFixedNumber = true; + + Object.freeze(this); + } + + #checkFormat(other: FixedNumber): void { + if (this.format.name !== other.format.name) { + logger.throwArgumentError("incompatible format; use fixedNumber.toFormat", "other", other); + } + } + + addUnsafe(other: FixedNumber): FixedNumber { + this.#checkFormat(other); + const a = parseFixed(this.#value, this.format.decimals); + const b = parseFixed(other.#value, other.format.decimals); + return FixedNumber.fromValue(a + b, this.format.decimals, this.format); + } + + subUnsafe(other: FixedNumber): FixedNumber { + this.#checkFormat(other); + const a = parseFixed(this.#value, this.format.decimals); + const b = parseFixed(other.#value, other.format.decimals); + return FixedNumber.fromValue(a - b, this.format.decimals, this.format); + } + + mulUnsafe(other: FixedNumber): FixedNumber { + this.#checkFormat(other); + const a = parseFixed(this.#value, this.format.decimals); + const b = parseFixed(other.#value, other.format.decimals); + return FixedNumber.fromValue((a * b) / this.format._multiplier, this.format.decimals, this.format); + } + + divUnsafe(other: FixedNumber): FixedNumber { + this.#checkFormat(other); + const a = parseFixed(this.#value, this.format.decimals); + const b = parseFixed(other.#value, other.format.decimals); + return FixedNumber.fromValue((a * this.format._multiplier) / b, this.format.decimals, this.format); + } + + floor(): FixedNumber { + const comps = this.toString().split("."); + if (comps.length === 1) { comps.push("0"); } + + let result = FixedNumber.from(comps[0], this.format); + + const hasFraction = !comps[1].match(/^(0*)$/); + if (this.isNegative() && hasFraction) { + result = result.subUnsafe(ONE.toFormat(result.format)); + } + + return result; + } + + ceiling(): FixedNumber { + const comps = this.toString().split("."); + if (comps.length === 1) { comps.push("0"); } + + let result = FixedNumber.from(comps[0], this.format); + + const hasFraction = !comps[1].match(/^(0*)$/); + if (!this.isNegative() && hasFraction) { + result = result.addUnsafe(ONE.toFormat(result.format)); + } + + return result; + } + + // @TODO: Support other rounding algorithms + round(decimals?: number): FixedNumber { + if (decimals == null) { decimals = 0; } + + // If we are already in range, we're done + const comps = this.toString().split("."); + if (comps.length === 1) { comps.push("0"); } + + if (decimals < 0 || decimals > 80 || (decimals % 1)) { + logger.throwArgumentError("invalid decimal count", "decimals", decimals); + } + + if (comps[1].length <= decimals) { return this; } + + const factor = FixedNumber.from("1" + zeros.substring(0, decimals), this.format); + const bump = BUMP.toFormat(this.format); + + return this.mulUnsafe(factor).addUnsafe(bump).floor().divUnsafe(factor); + } + + isZero(): boolean { + return (this.#value === "0.0" || this.#value === "0"); + } + + isNegative(): boolean { + return (this.#value[0] === "-"); + } + + toString(): string { return this.#value; } + + toHexString(_width: Numeric): string { + throw new Error("TODO"); + /* + return toHex(); + if (width == null) { return this.#hex; } + + const width = logger.getNumeric(_width); + if (width % 8) { logger.throwArgumentError("invalid byte width", "width", width); } + + const hex = BigNumber.from(this.#hex).fromTwos(this.format.width).toTwos(width).toHexString(); + return zeroPadLeft(hex, width / 8); + */ + } + + toUnsafeFloat(): number { return parseFloat(this.toString()); } + + toFormat(format: FixedFormat | string): FixedNumber { + return FixedNumber.fromString(this.#value, format); + } + + + static fromValue(value: BigNumberish, decimals = 0, format: FixedFormat | string | number = "fixed"): FixedNumber { + return FixedNumber.fromString(formatFixed(value, decimals), FixedFormat.from(format)); + } + + + static fromString(value: string, format: FixedFormat | string | number = "fixed"): FixedNumber { + const fixedFormat = FixedFormat.from(format); + const numeric = parseFixed(value, fixedFormat.decimals); + + if (!fixedFormat.signed && numeric < 0) { + throwFault("unsigned value cannot be negative", "overflow", "value", value); + } + + const hex = (function() { + if (fixedFormat.signed) { + return toHex(toTwos(numeric, fixedFormat.width)); + } + return toHex(numeric, fixedFormat.width / 8); + })(); + + const decimal = formatFixed(numeric, fixedFormat.decimals); + + return new FixedNumber(_constructorGuard, hex, decimal, fixedFormat); + } + + static fromBytes(_value: BytesLike, format: FixedFormat | string | number = "fixed"): FixedNumber { + const value = logger.getBytes(_value, "value"); + const fixedFormat = FixedFormat.from(format); + + if (value.length > fixedFormat.width / 8) { + throw new Error("overflow"); + } + + let numeric = toBigInt(value); + if (fixedFormat.signed) { numeric = fromTwos(numeric, fixedFormat.width); } + + const hex = toHex(toTwos(numeric, (fixedFormat.signed ? 0: 1) + fixedFormat.width)); + const decimal = formatFixed(numeric, fixedFormat.decimals); + + return new FixedNumber(_constructorGuard, hex, decimal, fixedFormat); + } + + static from(value: any, format?: FixedFormat | string | number) { + if (typeof(value) === "string") { + return FixedNumber.fromString(value, format); + } + + if (value instanceof Uint8Array) { + return FixedNumber.fromBytes(value, format); + } + + try { + return FixedNumber.fromValue(value, 0, format); + } catch (error: any) { + // Allow NUMERIC_FAULT to bubble up + if (error.code !== "INVALID_ARGUMENT") { + throw error; + } + } + + return logger.throwArgumentError("invalid FixedNumber value", "value", value); + } + + static isFixedNumber(value: any): value is FixedNumber { + return !!(value && value._isFixedNumber); + } +} + +const ONE = FixedNumber.from(1); +const BUMP = FixedNumber.from("0.5"); diff --git a/src.ts/utils/geturl-browser.ts b/src.ts/utils/geturl-browser.ts new file mode 100644 index 000000000..3c8b0dadf --- /dev/null +++ b/src.ts/utils/geturl-browser.ts @@ -0,0 +1,76 @@ +import { logger } from "./logger.js"; + +import type { FetchRequest, FetchCancelSignal, GetUrlResponse } from "./fetch.js"; + + +declare global { + class Headers { + constructor(values: Array<[ string, string ]>); + forEach(func: (v: string, k: string) => void): void; + } + + class Response { + status: number; + statusText: string; + headers: Headers; + arrayBuffer(): Promise; + } + + type FetchInit = { + method?: string, + headers?: Headers, + body?: Uint8Array + }; + + function fetch(url: string, init: FetchInit): Promise; +} + +// @TODO: timeout is completely ignored; start a Promise.any with a reject? + +export async function getUrl(req: FetchRequest, _signal?: FetchCancelSignal): Promise { + const protocol = req.url.split(":")[0].toLowerCase(); + + if (protocol !== "http" && protocol !== "https") { + logger.throwError(`unsupported protocol ${ protocol }`, "UNSUPPORTED_OPERATION", { + info: { protocol }, + operation: "request" + }); + } + + if (req.credentials && !req.allowInsecureAuthentication) { + logger.throwError("insecure authorized connections unsupported", "UNSUPPORTED_OPERATION", { + operation: "request" + }); + } + + let signal: undefined | AbortSignal = undefined; + if (_signal) { + const controller = new AbortController(); + signal = controller.signal; + _signal.addListener(() => { controller.abort(); }); + } + + const init = { + method: req.method, + headers: new Headers(Array.from(req)), + body: req.body || undefined, + signal + }; + + const resp = await fetch(req.url, init); + + const headers: Record = { }; + resp.headers.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + + const respBody = await resp.arrayBuffer(); + const body = (respBody == null) ? null: new Uint8Array(respBody); + + return { + statusCode: resp.status, + statusMessage: resp.statusText, + headers, body + }; +} + diff --git a/src.ts/utils/geturl.ts b/src.ts/utils/geturl.ts new file mode 100644 index 000000000..b87f206db --- /dev/null +++ b/src.ts/utils/geturl.ts @@ -0,0 +1,97 @@ +import http from "http"; +import https from "https"; +import { gunzipSync } from "zlib"; + +import { logger } from "./logger.js"; + +import type { FetchRequest, FetchCancelSignal, GetUrlResponse } from "./fetch.js"; + + +export async function getUrl(req: FetchRequest, signal?: FetchCancelSignal): Promise { + + const protocol = req.url.split(":")[0].toLowerCase(); + + if (protocol !== "http" && protocol !== "https") { + logger.throwError(`unsupported protocol ${ protocol }`, "UNSUPPORTED_OPERATION", { + info: { protocol }, + operation: "request" + }); + } + + if (req.credentials && !req.allowInsecureAuthentication) { + logger.throwError("insecure authorized connections unsupported", "UNSUPPORTED_OPERATION", { + operation: "request" + }); + } + + const method = req.method; + const headers = Object.assign({ }, req.headers); + + const options: any = { method, headers }; + + const request = ((protocol === "http") ? http: https).request(req.url, options); + + request.setTimeout(req.timeout); + + const body = req.body; + if (body) { request.write(Buffer.from(body)); } + + request.end(); + + return new Promise((resolve, reject) => { + // @TODO: Node 15 added AbortSignal; once we drop support for + // Node14, we can add that in here too + + request.once("response", (resp: http.IncomingMessage) => { + const statusCode = resp.statusCode || 0; + const statusMessage = resp.statusMessage || ""; + const headers = Object.keys(resp.headers || {}).reduce((accum, name) => { + let value = resp.headers[name] || ""; + if (Array.isArray(value)) { + value = value.join(", "); + } + accum[name] = value; + return accum; + }, <{ [ name: string ]: string }>{ }); + + let body: null | Uint8Array = null; + //resp.setEncoding("utf8"); + + resp.on("data", (chunk: Uint8Array) => { + if (signal) { + try { + signal.checkSignal(); + } catch (error) { + return reject(error); + } + } + + if (body == null) { + body = chunk; + } else { + const newBody = new Uint8Array(body.length + chunk.length); + newBody.set(body, 0); + newBody.set(chunk, body.length); + body = newBody; + } + }); + + resp.on("end", () => { + if (headers["content-encoding"] === "gzip" && body) { + body = logger.getBytes(gunzipSync(body)); + } + + resolve({ statusCode, statusMessage, headers, body }); + }); + + resp.on("error", (error) => { + //@TODO: Should this just return nornal response with a server error? + (error).response = { statusCode, statusMessage, headers, body }; + reject(error); + }); + }); + + request.on("error", (error) => { reject(error); }); + }); +} + diff --git a/src.ts/utils/index.ts b/src.ts/utils/index.ts new file mode 100644 index 000000000..5ba084fec --- /dev/null +++ b/src.ts/utils/index.ts @@ -0,0 +1,99 @@ + +//// + +export interface Freezable { + clone(): T; + freeze(): Frozen; + isFrozen(): boolean; +} + +export type Frozen = Readonly<{ + [ P in keyof T ]: T[P] extends (...args: Array) => any ? T[P]: + T[P] extends Freezable ? Frozen: + Readonly; +}>; + + +export { decodeBase58, encodeBase58 } from "./base58.js"; + +export { decodeBase64, encodeBase64 } from "./base64.js"; + +export { + isHexString, isBytesLike, hexlify, concat, dataLength, dataSlice, + stripZerosLeft, zeroPadValue, zeroPadBytes +} from "./data.js"; + +export { isCallException, isError } from "./errors.js" + +export { EventPayload } from "./events.js"; + +export { FetchRequest, FetchResponse } from "./fetch.js"; + +export { FixedFormat, FixedNumber, formatFixed, parseFixed } from "./fixednumber.js" + +export { assertArgument, Logger, logger } from "./logger.js"; + +export { + fromTwos, toTwos, mask, + toBigInt, toNumber, toHex, toArray, toQuantity +} from "./maths.js"; + +export { resolveProperties, defineReadOnly, defineProperties} from "./properties.js"; + +export { decodeRlp } from "./rlp-decode.js"; +export { encodeRlp } from "./rlp-encode.js"; + +export { getStore, setStore} from "./storage.js"; + +export { formatEther, parseEther, formatUnits, parseUnits } from "./units.js"; + +export { + _toEscapedUtf8String, + toUtf8Bytes, + toUtf8CodePoints, + toUtf8String, + + Utf8ErrorFuncs, +} from "./utf8.js"; + + +///////////////////////////// +// Types + +export type { BytesLike } from "./data.js"; + +export type { + + ErrorSignature, ErrorFetchRequestWithBody, ErrorFetchRequest, + ErrorFetchResponseWithBody, ErrorFetchResponse, + + ErrorCode, + + EthersError, UnknownError, NotImplementedError, UnsupportedOperationError, NetworkError, + ServerError, TimeoutError, BadDataError, CancelledError, BufferOverrunError, + NumericFaultError, InvalidArgumentError, MissingArgumentError, UnexpectedArgumentError, + CallExceptionError, InsufficientFundsError, NonceExpiredError, OffchainFaultError, + ReplacementUnderpricedError, TransactionReplacedError, UnconfiguredNameError, + UnpredictableGasLimitError, ActionRejectedError, + + CodedEthersError +} from "./errors.js" + +export type { EventEmitterable, Listener } from "./events.js"; + +export type { + GetUrlResponse, + FetchRequestWithBody, FetchResponseWithBody, + FetchPreflightFunc, FetchProcessFunc, FetchRetryFunc, + FetchGatewayFunc, FetchGetUrlFunc +} from "./fetch.js"; + +export { BigNumberish, Numeric } from "./maths.js"; + +export type { RlpStructuredData } from "./rlp.js"; + +export type { + Utf8ErrorFunc, + UnicodeNormalizationForm, + Utf8ErrorReason +} from "./utf8.js"; diff --git a/src.ts/utils/logger.ts b/src.ts/utils/logger.ts new file mode 100644 index 000000000..ec9fba1fb --- /dev/null +++ b/src.ts/utils/logger.ts @@ -0,0 +1,254 @@ +import { version } from "../_version.js"; + +import type { BigNumberish, BytesLike } from "./index.js"; + +import type { CodedEthersError, ErrorCode } from "./errors.js"; + + +export type ErrorInfo = Omit; + +export type LogLevel = "debug" | "info" | "warning" | "error" | "off"; + +const LogLevels: Array = [ "debug", "info", "warning", "error", "off" ]; + +const _normalizeForms = ["NFD", "NFC", "NFKD", "NFKC"].reduce((accum, form) => { + try { + // General test for normalize + /* c8 ignore start */ + if ("test".normalize(form) !== "test") { throw new Error("bad"); }; + /* c8 ignore stop */ + + if (form === "NFD") { + const check = String.fromCharCode(0xe9).normalize("NFD"); + const expected = String.fromCharCode(0x65, 0x0301) + /* c8 ignore start */ + if (check !== expected) { throw new Error("broken") } + /* c8 ignore stop */ + } + + accum.push(form); + } catch(error) { } + + return accum; +}, >[]); + +function defineReadOnly(object: T, name: P, value: T[P]): void { + Object.defineProperty(object, name, { + enumerable: true, writable: false, value, + }); +} + +// IEEE 754 support 53-bits of mantissa +const maxValue = 0x1fffffffffffff; + +// The type of error to use for various error codes +const ErrorConstructors: Record): Error }> = { }; +ErrorConstructors.INVALID_ARGUMENT = TypeError; +ErrorConstructors.NUMERIC_FAULT = RangeError; +ErrorConstructors.BUFFER_OVERRUN = RangeError; + +export type AssertFunc = () => (undefined | T); + +export class Logger { + readonly version!: string; + + #logLevel: number; + + constructor(version?: string) { + defineReadOnly(this, "version", version || "_"); + this.#logLevel = 1; + } + + get logLevel(): LogLevel { + return LogLevels[this.#logLevel]; + } + + set logLevel(value: LogLevel) { + const logLevel = LogLevels.indexOf(value); + if (logLevel == null) { + this.throwArgumentError("invalid logLevel", "logLevel", value); + } + this.#logLevel = logLevel; + } + + makeError>(message: string, code: K, info?: ErrorInfo): T { + { + const details: Array = []; + if (info) { + for (const key in info) { + const value = (info[>key]); + try { + details.push(key + "=" + JSON.stringify(value)); + } catch (error) { + details.push(key + "=[could not serialize object]"); + } + } + } + details.push(`code=${ code }`); + details.push(`version=${ this.version }`); + + if (details.length) { + message += " (" + details.join(", ") + ")"; + } + } + + const create = ErrorConstructors[code] || Error; + const error = (new create(message)); + defineReadOnly(error, "code", code); + if (info) { + for (const key in info) { + defineReadOnly(error, key, (info[>key])); + } + } + return error; + } + + throwError>(message: string, code: K, info?: ErrorInfo): never { + throw this.makeError(message, code, info); + } + + throwArgumentError(message: string, name: string, value: any): never { + return this.throwError(message, "INVALID_ARGUMENT", { + argument: name, + value: value + }); + } + + assertNormalize(form: string): void { + if (_normalizeForms.indexOf(form) === -1) { + this.throwError("platform missing String.prototype.normalize", "UNSUPPORTED_OPERATION", { + operation: "String.prototype.normalize", info: { form } + }); + } + } + + assertPrivate(givenGuard: any, guard: any, className = ""): void { + if (givenGuard !== guard) { + let method = className, operation = "new"; + if (className) { + method += "."; + operation += " " + className; + } + this.throwError(`private constructor; use ${ method }from* methods`, "UNSUPPORTED_OPERATION", { + operation + }); + } + } + + assertArgumentCount(count: number, expectedCount: number, message: string = ""): void { + if (message) { message = ": " + message; } + + if (count < expectedCount) { + this.throwError("missing arguemnt" + message, "MISSING_ARGUMENT", { + count: count, + expectedCount: expectedCount + }); + } + + if (count > expectedCount) { + this.throwError("too many arguemnts" + message, "UNEXPECTED_ARGUMENT", { + count: count, + expectedCount: expectedCount + }); + } + } + + #getBytes(value: BytesLike, name?: string, copy?: boolean): Uint8Array { + if (value instanceof Uint8Array) { + if (copy) { return new Uint8Array(value); } + return value; + } + + if (typeof(value) === "string" && value.match(/^0x([0-9a-f][0-9a-f])*$/i)) { + const result = new Uint8Array((value.length - 2) / 2); + let offset = 2; + for (let i = 0; i < result.length; i++) { + result[i] = parseInt(value.substring(offset, offset + 2), 16); + offset += 2; + } + return result; + } + + return this.throwArgumentError("invalid BytesLike value", name || "value", value); + } + + getBytes(value: BytesLike, name?: string): Uint8Array { + return this.#getBytes(value, name, false); + } + + getBytesCopy(value: BytesLike, name?: string): Uint8Array { + return this.#getBytes(value, name, true); + } + + getNumber(value: BigNumberish, name?: string): number { + switch (typeof(value)) { + case "bigint": + if (value < -maxValue || value > maxValue) { + this.throwArgumentError("overflow", name || "value", value); + } + return Number(value); + case "number": + if (!Number.isInteger(value)) { + this.throwArgumentError("underflow", name || "value", value); + } else if (value < -maxValue || value > maxValue) { + this.throwArgumentError("overflow", name || "value", value); + } + return value; + case "string": + try { + return this.getNumber(BigInt(value), name); + } catch(e: any) { + this.throwArgumentError(`invalid numeric string: ${ e.message }`, name || "value", value); + } + } + return this.throwArgumentError("invalid numeric value", name || "value", value); + } + + getBigInt(value: BigNumberish, name?: string): bigint { + switch (typeof(value)) { + case "bigint": return value; + case "number": + if (!Number.isInteger(value)) { + this.throwArgumentError("underflow", name || "value", value); + } else if (value < -maxValue || value > maxValue) { + this.throwArgumentError("overflow", name || "value", value); + } + return BigInt(value); + case "string": + try { + return BigInt(value); + } catch(e: any) { + this.throwArgumentError(`invalid BigNumberish string: ${ e.message }`, name || "value", value); + } + } + return this.throwArgumentError("invalid BigNumberish value", name || "value", value); + } + + #log(_logLevel: LogLevel, args: Array): void { + const logLevel = LogLevels.indexOf(_logLevel); + if (logLevel === -1) { + this.throwArgumentError("invalid log level name", "logLevel", _logLevel); + } + if (this.#logLevel > logLevel) { return; } + console.log.apply(console, args); + } + + debug(...args: Array): void { + this.#log("debug", args); + } + + info(...args: Array): void { + this.#log("info", args); + } + + warn(...args: Array): void { + this.#log("warning", args); + } +} + +export const logger = new Logger(version); + +export function assertArgument(check: unknown, message: string, name: string, value: unknown): asserts check { + if (!check) { logger.throwArgumentError(message, name, value); } +} + diff --git a/src.ts/utils/maths.ts b/src.ts/utils/maths.ts new file mode 100644 index 000000000..a50d52f01 --- /dev/null +++ b/src.ts/utils/maths.ts @@ -0,0 +1,134 @@ +import { hexlify, isBytesLike } from "./data.js"; +import { logger } from "./logger.js"; + +import type { BytesLike } from "./data.js"; + + +export type Numeric = number | bigint; +export type BigNumberish = string | Numeric; + + +const BN_0 = BigInt(0); +const BN_1 = BigInt(1); + +/** + * Convert %%value%% from a twos-compliment value of %%width%% bits. + */ +export function fromTwos(_value: BigNumberish, _width: Numeric): bigint { + const value = logger.getBigInt(_value, "value"); + const width = BigInt(logger.getNumber(_width, "width")); + + // Top bit set; treat as a negative value + if (value >> (width - BN_1)) { + const mask = (BN_1 << width) - BN_1; + return -(((~value) & mask) + BN_1); + } + + return value; +} + +/** + * Convert %%value%% to a twos-compliment value of %%width%% bits. + */ +export function toTwos(_value: BigNumberish, _width: Numeric): bigint { + const value = logger.getBigInt(_value, "value"); + const width = BigInt(logger.getNumber(_width, "width")); + + if (value < BN_0) { + const mask = (BN_1 << width) - BN_1; + return ((~(-value)) & mask) + BN_1; + } + + return value; +} + +/** + * Mask %%value%% with a bitmask of %%bits%% ones. + */ +export function mask(_value: BigNumberish, _bits: Numeric): bigint { + const value = logger.getBigInt(_value, "value"); + const bits = BigInt(logger.getNumber(_bits, "bits")); + return value & ((BN_1 << bits) - BN_1); +} + + + +/* + * Converts %%value%% to a BigInt. If %%value%% is a Uint8Array, it + * is treated as Big Endian data. + */ +const Nibbles = "0123456789abcdef"; +export function toBigInt(value: BigNumberish | Uint8Array): bigint { + if (value instanceof Uint8Array) { + let result = "0x0"; + for (const v of value) { + result += Nibbles[v >> 4]; + result += Nibbles[v & 0x0f]; + } + return BigInt(result); + } + + return logger.getBigInt(value); +} + +/* + * Converts %%value%% to a number. If %%value%% is a Uint8Array, it + * is treated as Big Endian data. Throws if the value is not safe. + */ +export function toNumber(value: BigNumberish | Uint8Array): number { + return logger.getNumber(toBigInt(value)); +} + +/** + * Converts %%value%% to a Big Endian hexstring, optionally padded to + * %%width%% bytes. + */ +// Converts value to hex, optionally padding on the left to width bytes +export function toHex(_value: BigNumberish, _width?: Numeric): string { + const value = logger.getBigInt(_value, "value"); + if (value < 0) { throw new Error("cannot convert negative value to hex"); } + + let result = value.toString(16); + + if (_width == null) { + // Ensure the value is of even length + if (result.length % 2) { result = "0" + result; } + } else { + const width = logger.getNumber(_width, "width"); + if (width * 2 < result.length) { throw new Error(`value ${ value } exceeds width ${ width }`); } + + // Pad the value to the required width + while (result.length < (width * 2)) { result = "0" + result; } + + } + + return "0x" + result; +} + +/** + * Converts %%value%% to a Big Endian Uint8Array. + */ +export function toArray(_value: BigNumberish): Uint8Array { + const value = logger.getBigInt(_value, "value"); + if (value < 0) { throw new Error("cannot convert negative value to hex"); } + + if (value === BN_0) { return new Uint8Array([ ]); } + + let hex = value.toString(16); + if (hex.length % 2) { hex = "0" + hex; } + + const result = new Uint8Array(hex.length / 2); + for (let i = 0; i < result.length; i++) { + const offset = i * 2; + result[i] = parseInt(hex.substring(offset, offset + 2), 16); + } + + return result; +} + +export function toQuantity(value: BytesLike | BigNumberish): string { + let result = hexlify(isBytesLike(value) ? value: toArray(value)).substring(2); + while (result.substring(0, 1) === "0") { result = result.substring(1); } + if (result === "") { result = "0"; } + return "0x" + result; +} diff --git a/src.ts/utils/properties.ts b/src.ts/utils/properties.ts new file mode 100644 index 000000000..bbaac7f71 --- /dev/null +++ b/src.ts/utils/properties.ts @@ -0,0 +1,110 @@ +export async function resolveProperties(value: { [ P in keyof T ]: T[P] | Promise}): Promise { + const keys = Object.keys(value); + const results = await Promise.all(keys.map((k) => Promise.resolve(value[k]))); + return results.reduce((accum: any, v, index) => { + accum[keys[index]] = v; + return accum; + }, <{ [ P in keyof T]: T[P] }>{ }); +} + +export function defineReadOnly(object: T, name: P, value: T[P]): void { + Object.defineProperty(object, name, { + enumerable: true, + value: value, + writable: false, + }); +} + +/* +export interface CancellablePromise extends Promise { + cancel(): Promise; +} +export type IsCancelled = () => Promise; + +export function createPromise(resolve: (isCancelled: IsCancelled, (result: T) => void) => void, reject: (error: Error) => void, isCancelled: IsCancelled): CancellablePromise { + let cancelled = false; + + const promise = new Promise((resolve, reject) => { + + }); + + (>promise).cancel = function() { + cancelled = true; + }; + + return (>promise); +} +*/ +/* +export class A implements Freezable { + foo: number; + constructor(foo: number) { + this.foo = foo; + } + freeze(): Frozen { + Object.freeze(this); + return this; + } + clone(): A { + return new A(this.foo); + } +} + +export class B implements Freezable { + a: A; + constructor(a: A) { + this.a = a; + } + freeze(): Frozen { + this.a.freeze(); + Object.freeze(this); + return this; + } + clone(): B { + return new B(this.a); + } +} + +export function test() { + const a = new A(123); + const b = new B(a); + b.a = new A(234); + const b2 = b.freeze(); + b2.a.foo = 123; // = a; +} +*/ + +function checkType(value: any, type: string): void { + const types = type.split("|").map(t => t.trim()); + for (let i = 0; i < types.length; i++) { + switch (type) { + case "any": + return; + case "boolean": + case "number": + case "string": + if (typeof(value) === type) { return; } + } + } + throw new Error("invalid value for type"); +} + +export function defineProperties( + target: T, + values: { [ K in keyof T ]?: undefined | T[K] }, + types?: { [ K in keyof T ]?: string }, + defaults?: { [ K in keyof T ]?: T[K] }): void { + + for (let key in values) { + let value = values[key]; + + const fallback = (defaults ? defaults[key]: undefined); + if (fallback !== undefined) { + value = fallback; + } else { + const type = (types ? types[key]: null); + if (type) { checkType(value, type); } + } + Object.defineProperty(target, key, { enumerable: true, value, writable: false }); + } +} diff --git a/src.ts/utils/rlp-decode.ts b/src.ts/utils/rlp-decode.ts new file mode 100644 index 000000000..cbc41ad42 --- /dev/null +++ b/src.ts/utils/rlp-decode.ts @@ -0,0 +1,108 @@ +//See: https://github.com/ethereum/wiki/wiki/RLP + +import { hexlify } from "./data.js"; +import { logger } from "./logger.js"; + +import type { BytesLike, RlpStructuredData } from "./index.js"; + + +function hexlifyByte(value: number): string { + let result = value.toString(16); + while (result.length < 2) { result = "0" + result; } + return "0x" + result; +} + +function unarrayifyInteger(data: Uint8Array, offset: number, length: number): number { + let result = 0; + for (let i = 0; i < length; i++) { + result = (result * 256) + data[offset + i]; + } + return result; +} + +type Decoded = { + result: any; + consumed: number; +}; + +function _decodeChildren(data: Uint8Array, offset: number, childOffset: number, length: number): Decoded { + const result = []; + + while (childOffset < offset + 1 + length) { + const decoded = _decode(data, childOffset); + + result.push(decoded.result); + + childOffset += decoded.consumed; + if (childOffset > offset + 1 + length) { + logger.throwError("child data too short", "BUFFER_OVERRUN", { + buffer: data, length, offset + }); + } + } + + return {consumed: (1 + length), result: result}; +} + +// returns { consumed: number, result: Object } +function _decode(data: Uint8Array, offset: number): { consumed: number, result: any } { + if (data.length === 0) { + logger.throwError("data too short", "BUFFER_OVERRUN", { + buffer: data, length: 0, offset: 1 + }); + } + + const checkOffset = (offset: number) => { + if (offset > data.length) { + logger.throwError("data short segment too short", "BUFFER_OVERRUN", { + buffer: data, length: data.length, offset + }); + } + }; + + // Array with extra length prefix + if (data[offset] >= 0xf8) { + const lengthLength = data[offset] - 0xf7; + checkOffset(offset + 1 + lengthLength); + + const length = unarrayifyInteger(data, offset + 1, lengthLength); + checkOffset(offset + 1 + lengthLength + length); + + return _decodeChildren(data, offset, offset + 1 + lengthLength, lengthLength + length); + + } else if (data[offset] >= 0xc0) { + const length = data[offset] - 0xc0; + checkOffset(offset + 1 + length); + + return _decodeChildren(data, offset, offset + 1, length); + + } else if (data[offset] >= 0xb8) { + const lengthLength = data[offset] - 0xb7; + checkOffset(offset + 1 + lengthLength); + + const length = unarrayifyInteger(data, offset + 1, lengthLength); + checkOffset(offset + 1 + lengthLength + length); + + const result = hexlify(data.slice(offset + 1 + lengthLength, offset + 1 + lengthLength + length)); + return { consumed: (1 + lengthLength + length), result: result } + + } else if (data[offset] >= 0x80) { + const length = data[offset] - 0x80; + checkOffset(offset + 1 + length); + + const result = hexlify(data.slice(offset + 1, offset + 1 + length)); + return { consumed: (1 + length), result: result } + } + + return { consumed: 1, result: hexlifyByte(data[offset]) }; +} + +export function decodeRlp(_data: BytesLike): RlpStructuredData { + const data = logger.getBytes(_data, "data"); + const decoded = _decode(data, 0); + if (decoded.consumed !== data.length) { + logger.throwArgumentError("unexpected junk after rlp payload", "data", _data); + } + return decoded.result; +} + diff --git a/src.ts/utils/rlp-encode.ts b/src.ts/utils/rlp-encode.ts new file mode 100644 index 000000000..37494d8e5 --- /dev/null +++ b/src.ts/utils/rlp-encode.ts @@ -0,0 +1,61 @@ +//See: https://github.com/ethereum/wiki/wiki/RLP + +import { logger } from "./logger.js"; + +import type { RlpStructuredData } from "./rlp.js"; + + +function arrayifyInteger(value: number): Array { + const result = []; + while (value) { + result.unshift(value & 0xff); + value >>= 8; + } + return result; +} + +function _encode(object: Array | string): Array { + if (Array.isArray(object)) { + let payload: Array = []; + object.forEach(function(child) { + payload = payload.concat(_encode(child)); + }); + + if (payload.length <= 55) { + payload.unshift(0xc0 + payload.length) + return payload; + } + + const length = arrayifyInteger(payload.length); + length.unshift(0xf7 + length.length); + + return length.concat(payload); + + } + + const data: Array = Array.prototype.slice.call(logger.getBytes(object, "object")); + + if (data.length === 1 && data[0] <= 0x7f) { + return data; + + } else if (data.length <= 55) { + data.unshift(0x80 + data.length); + return data; + } + + const length = arrayifyInteger(data.length); + length.unshift(0xb7 + length.length); + + return length.concat(data); +} + +const nibbles = "0123456789abcdef"; + +export function encodeRlp(object: RlpStructuredData): string { + let result = "0x"; + for (const v of _encode(object)) { + result += nibbles[v >> 4]; + result += nibbles[v & 0xf]; + } + return result; +} diff --git a/src.ts/utils/rlp.ts b/src.ts/utils/rlp.ts new file mode 100644 index 000000000..8f5c7209d --- /dev/null +++ b/src.ts/utils/rlp.ts @@ -0,0 +1,5 @@ + +export type RlpStructuredData = string | Array; + +export { decodeRlp } from "./rlp-decode.js"; +export { encodeRlp } from "./rlp-encode.js"; diff --git a/src.ts/utils/storage.ts b/src.ts/utils/storage.ts new file mode 100644 index 000000000..c3049a7e3 --- /dev/null +++ b/src.ts/utils/storage.ts @@ -0,0 +1,10 @@ +export function getStore(store: T, key: P): T[P] { + return store[key]; +} + +export function setStore(store: T, key: P, value: T[P]): void { + if (Object.isFrozen(store)) { + throw new Error(`frozen object is immuatable; cannot set ${ String(key) }`); + } + store[key] = value; +} diff --git a/src.ts/utils/units.ts b/src.ts/utils/units.ts new file mode 100644 index 000000000..b9556a582 --- /dev/null +++ b/src.ts/utils/units.ts @@ -0,0 +1,63 @@ +import { formatFixed, parseFixed } from "./fixednumber.js"; +import { logger } from "./logger.js"; + +import type { BigNumberish, Numeric } from "../utils/index.js"; + + +const names = [ + "wei", + "kwei", + "mwei", + "gwei", + "szabo", + "finney", + "ether", +]; + +/** + * Converts %%value%% into a //decimal string//, assuming %%unit%% decimal + * places. The %%unit%% may be the number of decimal places or the name of + * a unit (e.g. ``"gwei"`` for 9 decimal places). + * + */ +export function formatUnits(value: BigNumberish, unit?: string | Numeric): string { + if (typeof(unit) === "string") { + const index = names.indexOf(unit); + if (index === -1) { logger.throwArgumentError("invalid unit", "unit", unit); } + unit = 3 * index; + } + return formatFixed(value, (unit != null) ? unit: 18); +} + +/** + * Converts the //decimal string// %%value%% to a [[BigInt]], assuming + * %%unit%% decimal places. The %%unit%% may the number of decimal places + * or the name of a unit (e.g. ``"gwei"`` for 9 decimal places). + */ +export function parseUnits(value: string, unit?: string | Numeric): bigint { + if (typeof(value) !== "string") { + logger.throwArgumentError("value must be a string", "value", value); + } + + if (typeof(unit) === "string") { + const index = names.indexOf(unit); + if (index === -1) { logger.throwArgumentError("invalid unit", "unit", unit); } + unit = 3 * index; + } + return parseFixed(value, (unit != null) ? unit: 18); +} + +/** + * Converts %%value%% into a //decimal string// using 18 decimal places. + */ +export function formatEther(wei: BigNumberish): string { + return formatUnits(wei, 18); +} + +/** + * Converts the //decimal string// %%ether%% to a [[BigInt]], using 18 + * decimal places. + */ +export function parseEther(ether: string): bigint { + return parseUnits(ether, 18); +} diff --git a/src.ts/utils/utf8.ts b/src.ts/utils/utf8.ts new file mode 100644 index 000000000..362b598bc --- /dev/null +++ b/src.ts/utils/utf8.ts @@ -0,0 +1,285 @@ +import { logger } from "./logger.js"; + +import type { BytesLike } from "./index.js"; + + +/////////////////////////////// + +export type UnicodeNormalizationForm = "NFC" | "NFD" | "NFKC" | "NFKD"; + +export type Utf8ErrorReason = + // A continuation byte was present where there was nothing to continue + // - offset = the index the codepoint began in + "UNEXPECTED_CONTINUE" | + + // An invalid (non-continuation) byte to start a UTF-8 codepoint was found + // - offset = the index the codepoint began in + "BAD_PREFIX" | + + // The string is too short to process the expected codepoint + // - offset = the index the codepoint began in + "OVERRUN" | + + // A missing continuation byte was expected but not found + // - offset = the index the continuation byte was expected at + "MISSING_CONTINUE" | + + // The computed code point is outside the range for UTF-8 + // - offset = start of this codepoint + // - badCodepoint = the computed codepoint; outside the UTF-8 range + "OUT_OF_RANGE" | + + // UTF-8 strings may not contain UTF-16 surrogate pairs + // - offset = start of this codepoint + // - badCodepoint = the computed codepoint; inside the UTF-16 surrogate range + "UTF16_SURROGATE" | + + // The string is an overlong representation + // - offset = start of this codepoint + // - badCodepoint = the computed codepoint; already bounds checked + "OVERLONG"; + + +export type Utf8ErrorFunc = (reason: Utf8ErrorReason, offset: number, bytes: ArrayLike, output: Array, badCodepoint?: number) => number; + +function errorFunc(reason: Utf8ErrorReason, offset: number, bytes: ArrayLike, output: Array, badCodepoint?: number): number { + return logger.throwArgumentError(`invalid codepoint at offset ${ offset }; ${ reason }`, "bytes", bytes); +} + +function ignoreFunc(reason: Utf8ErrorReason, offset: number, bytes: ArrayLike, output: Array, badCodepoint?: number): number { + + // If there is an invalid prefix (including stray continuation), skip any additional continuation bytes + if (reason === "BAD_PREFIX" || reason === "UNEXPECTED_CONTINUE") { + let i = 0; + for (let o = offset + 1; o < bytes.length; o++) { + if (bytes[o] >> 6 !== 0x02) { break; } + i++; + } + return i; + } + + // This byte runs us past the end of the string, so just jump to the end + // (but the first byte was read already read and therefore skipped) + if (reason === "OVERRUN") { + return bytes.length - offset - 1; + } + + // Nothing to skip + return 0; +} + +function replaceFunc(reason: Utf8ErrorReason, offset: number, bytes: ArrayLike, output: Array, badCodepoint?: number): number { + + // Overlong representations are otherwise "valid" code points; just non-deistingtished + if (reason === "OVERLONG") { + output.push((badCodepoint != null) ? badCodepoint: -1); + return 0; + } + + // Put the replacement character into the output + output.push(0xfffd); + + // Otherwise, process as if ignoring errors + return ignoreFunc(reason, offset, bytes, output, badCodepoint); +} + +// Common error handing strategies +export const Utf8ErrorFuncs: Readonly> = Object.freeze({ + error: errorFunc, + ignore: ignoreFunc, + replace: replaceFunc +}); + +// http://stackoverflow.com/questions/13356493/decode-utf-8-with-javascript#13691499 +function getUtf8CodePoints(_bytes: BytesLike, onError?: Utf8ErrorFunc): Array { + if (onError == null) { onError = Utf8ErrorFuncs.error; } + + const bytes = logger.getBytes(_bytes, "bytes"); + + const result: Array = []; + let i = 0; + + // Invalid bytes are ignored + while(i < bytes.length) { + + const c = bytes[i++]; + + // 0xxx xxxx + if (c >> 7 === 0) { + result.push(c); + continue; + } + + // Multibyte; how many bytes left for this character? + let extraLength = null; + let overlongMask = null; + + // 110x xxxx 10xx xxxx + if ((c & 0xe0) === 0xc0) { + extraLength = 1; + overlongMask = 0x7f; + + // 1110 xxxx 10xx xxxx 10xx xxxx + } else if ((c & 0xf0) === 0xe0) { + extraLength = 2; + overlongMask = 0x7ff; + + // 1111 0xxx 10xx xxxx 10xx xxxx 10xx xxxx + } else if ((c & 0xf8) === 0xf0) { + extraLength = 3; + overlongMask = 0xffff; + + } else { + if ((c & 0xc0) === 0x80) { + i += onError("UNEXPECTED_CONTINUE", i - 1, bytes, result); + } else { + i += onError("BAD_PREFIX", i - 1, bytes, result); + } + continue; + } + + // Do we have enough bytes in our data? + if (i - 1 + extraLength >= bytes.length) { + i += onError("OVERRUN", i - 1, bytes, result); + continue; + } + + // Remove the length prefix from the char + let res: null | number = c & ((1 << (8 - extraLength - 1)) - 1); + + for (let j = 0; j < extraLength; j++) { + let nextChar = bytes[i]; + + // Invalid continuation byte + if ((nextChar & 0xc0) != 0x80) { + i += onError("MISSING_CONTINUE", i, bytes, result); + res = null; + break; + }; + + res = (res << 6) | (nextChar & 0x3f); + i++; + } + + // See above loop for invalid continuation byte + if (res === null) { continue; } + + // Maximum code point + if (res > 0x10ffff) { + i += onError("OUT_OF_RANGE", i - 1 - extraLength, bytes, result, res); + continue; + } + + // Reserved for UTF-16 surrogate halves + if (res >= 0xd800 && res <= 0xdfff) { + i += onError("UTF16_SURROGATE", i - 1 - extraLength, bytes, result, res); + continue; + } + + // Check for overlong sequences (more bytes than needed) + if (res <= overlongMask) { + i += onError("OVERLONG", i - 1 - extraLength, bytes, result, res); + continue; + } + + result.push(res); + } + + return result; +} + +// http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array +export function toUtf8Bytes(str: string, form?: UnicodeNormalizationForm): Uint8Array { + + if (form != null) { + logger.assertNormalize(form); + str = str.normalize(form); + } + + let result = []; + for (let i = 0; i < str.length; i++) { + const c = str.charCodeAt(i); + + if (c < 0x80) { + result.push(c); + + } else if (c < 0x800) { + result.push((c >> 6) | 0xc0); + result.push((c & 0x3f) | 0x80); + + } else if ((c & 0xfc00) == 0xd800) { + i++; + const c2 = str.charCodeAt(i); + + if (i >= str.length || (c2 & 0xfc00) !== 0xdc00) { + throw new Error("invalid utf-8 string"); + } + + // Surrogate Pair + const pair = 0x10000 + ((c & 0x03ff) << 10) + (c2 & 0x03ff); + result.push((pair >> 18) | 0xf0); + result.push(((pair >> 12) & 0x3f) | 0x80); + result.push(((pair >> 6) & 0x3f) | 0x80); + result.push((pair & 0x3f) | 0x80); + + } else { + result.push((c >> 12) | 0xe0); + result.push(((c >> 6) & 0x3f) | 0x80); + result.push((c & 0x3f) | 0x80); + } + } + + return new Uint8Array(result); +}; + +function escapeChar(value: number) { + const hex = ("0000" + value.toString(16)); + return "\\u" + hex.substring(hex.length - 4); +} + +export function _toEscapedUtf8String(bytes: BytesLike, onError?: Utf8ErrorFunc): string { + return '"' + getUtf8CodePoints(bytes, onError).map((codePoint) => { + if (codePoint < 256) { + switch (codePoint) { + case 8: return "\\b"; + case 9: return "\\t"; + case 10: return "\\n" + case 13: return "\\r"; + case 34: return "\\\""; + case 92: return "\\\\"; + } + + if (codePoint >= 32 && codePoint < 127) { + return String.fromCharCode(codePoint); + } + } + + if (codePoint <= 0xffff) { + return escapeChar(codePoint); + } + + codePoint -= 0x10000; + return escapeChar(((codePoint >> 10) & 0x3ff) + 0xd800) + escapeChar((codePoint & 0x3ff) + 0xdc00); + }).join("") + '"'; +} + +export function _toUtf8String(codePoints: Array): string { + return codePoints.map((codePoint) => { + if (codePoint <= 0xffff) { + return String.fromCharCode(codePoint); + } + codePoint -= 0x10000; + return String.fromCharCode( + (((codePoint >> 10) & 0x3ff) + 0xd800), + ((codePoint & 0x3ff) + 0xdc00) + ); + }).join(""); +} + +export function toUtf8String(bytes: BytesLike, onError?: Utf8ErrorFunc): string { + return _toUtf8String(getUtf8CodePoints(bytes, onError)); +} + +export function toUtf8CodePoints(str: string, form?: UnicodeNormalizationForm): Array { + return getUtf8CodePoints(toUtf8Bytes(str, form)); +} diff --git a/src.ts/wallet/base-wallet.ts b/src.ts/wallet/base-wallet.ts new file mode 100644 index 000000000..3bbe2b629 --- /dev/null +++ b/src.ts/wallet/base-wallet.ts @@ -0,0 +1,84 @@ +import { getAddress, resolveAddress } from "../address/index.js"; +import { hashMessage, TypedDataEncoder } from "../hash/index.js"; +import { AbstractSigner } from "../providers/index.js"; +import { computeAddress, Transaction } from "../transaction/index.js"; +import { defineProperties, logger, resolveProperties } from "../utils/index.js"; + +import type { SigningKey } from "../crypto/index.js"; +import type { TypedDataDomain, TypedDataField } from "../hash/index.js"; +import type { Provider, TransactionRequest } from "../providers/index.js"; +import type { TransactionLike } from "../transaction/index.js"; + + +export class BaseWallet extends AbstractSigner { + readonly address!: string; + + readonly #signingKey: SigningKey; + + constructor(privateKey: SigningKey, provider?: null | Provider) { + super(provider); + this.#signingKey = privateKey; + + const address = computeAddress(this.signingKey.publicKey); + defineProperties(this, { address }); + } + + // Store these in getters to reduce visibility in console.log + get signingKey(): SigningKey { return this.#signingKey; } + get privateKey(): string { return this.signingKey.privateKey; } + + async getAddress(): Promise { return this.address; } + + connect(provider: null | Provider): BaseWallet { + return new BaseWallet(this.#signingKey, provider); + } + + async signTransaction(_tx: TransactionRequest): Promise { + // Replace any Addressable or ENS name with an address + const tx = >Object.assign({ }, _tx, await resolveProperties({ + to: (_tx.to ? resolveAddress(_tx.to, this.provider): undefined), + from: (_tx.from ? resolveAddress(_tx.from, this.provider): undefined) + })); + + if (tx.from != null) { + if (getAddress(tx.from) !== this.address) { + logger.throwArgumentError("transaction from address mismatch", "tx.from", _tx.from); + } + delete tx.from; + } + + // Build the transaction + const btx = Transaction.from(tx); + btx.signature = this.signingKey.sign(btx.unsignedHash); + + return btx.serialized; + } + + async signMessage(message: string | Uint8Array): Promise { + return this.signingKey.sign(hashMessage(message)).serialized; + } + + async signTypedData(domain: TypedDataDomain, types: Record>, value: Record): Promise { + + // Populate any ENS names + const populated = await TypedDataEncoder.resolveNames(domain, types, value, async (name: string) => { + if (this.provider == null) { + return logger.throwError("cannot resolve ENS names without a provider", "UNSUPPORTED_OPERATION", { + operation: "resolveName", + info: { name } + }); + } + + const address = await this.provider.resolveName(name); + if (address == null) { + return logger.throwError("unconfigured ENS name", "UNCONFIGURED_NAME", { + value: name + }); + } + + return address; + }); + + return this.signingKey.sign(TypedDataEncoder.hash(populated.domain, types, populated.value)).serialized; + } +} diff --git a/src.ts/wallet/hdwallet.ts b/src.ts/wallet/hdwallet.ts new file mode 100644 index 000000000..a7077a88c --- /dev/null +++ b/src.ts/wallet/hdwallet.ts @@ -0,0 +1,352 @@ +import { computeHmac, randomBytes, ripemd160, SigningKey, sha256 } from "../crypto/index.js"; +import { VoidSigner } from "../providers/index.js"; +import { computeAddress } from "../transaction/index.js"; +import { + concat, dataSlice, decodeBase58, defineProperties, encodeBase58, hexlify, logger, toBigInt, toHex +} from "../utils/index.js"; +import { langEn } from "../wordlists/lang-en.js"; + +import { Mnemonic } from "./mnemonic.js"; +import { BaseWallet } from "./base-wallet.js"; + +import type { BytesLike, Numeric } from "../utils/index.js"; +import type { Provider } from "../providers/index.js"; +import type { Wordlist } from "../wordlists/index.js"; + + +export const defaultPath = "m/44'/60'/0'/0/0"; + + +// "Bitcoin seed" +const MasterSecret = new Uint8Array([ 66, 105, 116, 99, 111, 105, 110, 32, 115, 101, 101, 100 ]); + +const HardenedBit = 0x80000000; + +const N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + +const Nibbles = "0123456789abcdef"; +function zpad(value: number, length: number): string { + let result = ""; + while (value) { + result = Nibbles[value % 16] + result; + value = Math.trunc(value / 16); + } + while (result.length < length * 2) { result = "0" + result; } + return "0x" + result; +} + +function encodeBase58Check(_value: BytesLike): string { + const value = logger.getBytes(_value); + const check = dataSlice(sha256(sha256(value)), 0, 4); + const bytes = concat([ value, check ]); + return encodeBase58(bytes); +} + +const _guard = { }; + +function ser_I(index: number, chainCode: string, publicKey: string, privateKey: null | string): { IL: Uint8Array, IR: Uint8Array } { + const data = new Uint8Array(37); + + if (index & HardenedBit) { + if (privateKey == null) { + return logger.throwError("cannot derive child of neutered node", "UNSUPPORTED_OPERATION", { + operation: "deriveChild" + }); + } + + // Data = 0x00 || ser_256(k_par) + data.set(logger.getBytes(privateKey), 1); + + } else { + // Data = ser_p(point(k_par)) + data.set(logger.getBytes(publicKey)); + } + + // Data += ser_32(i) + for (let i = 24; i >= 0; i -= 8) { data[33 + (i >> 3)] = ((index >> (24 - i)) & 0xff); } + const I = logger.getBytes(computeHmac("sha512", chainCode, data)); + + return { IL: I.slice(0, 32), IR: I.slice(32) }; +} + +type HDNodeLike = { depth: number, deriveChild: (i: number) => T }; +function derivePath>(node: T, path: string): T { + const components = path.split("/"); + + if (components.length === 0 || (components[0] === "m" && node.depth !== 0)) { + throw new Error("invalid path - " + path); + } + + if (components[0] === "m") { components.shift(); } + + let result: T = node; + for (let i = 0; i < components.length; i++) { + const component = components[i]; + + if (component.match(/^[0-9]+'$/)) { + const index = parseInt(component.substring(0, component.length - 1)); + if (index >= HardenedBit) { throw new Error("invalid path index - " + component); } + result = result.deriveChild(HardenedBit + index); + + } else if (component.match(/^[0-9]+$/)) { + const index = parseInt(component); + if (index >= HardenedBit) { throw new Error("invalid path index - " + component); } + result = result.deriveChild(index); + + } else { + throw new Error("invalid path component - " + component); + } + } + + return result; +} + + +export interface HDNodeWithPath { + path: string; +} + +export class HDNodeWallet extends BaseWallet { + readonly publicKey!: string; + + readonly fingerprint!: string; + readonly parentFingerprint!: string; + + readonly mnemonic!: null | Mnemonic; + + readonly chainCode!: string; + + readonly path!: null | string; + readonly index!: number; + readonly depth!: number; + + constructor(guard: any, signingKey: SigningKey, parentFingerprint: string, chainCode: string, path: null | string, index: number, depth: number, mnemonic: null | Mnemonic, provider: null | Provider) { + super(signingKey, provider); + logger.assertPrivate(guard, _guard, "HDNodeWallet"); + + defineProperties(this, { publicKey: signingKey.compressedPublicKey }); + + const fingerprint = dataSlice(ripemd160(sha256(this.publicKey)), 0, 4); + defineProperties(this, { + parentFingerprint, fingerprint, + chainCode, path, index, depth + }); + + defineProperties(this, { mnemonic }); + } + + connect(provider: null | Provider): HDNodeWallet { + return new HDNodeWallet(_guard, this.signingKey, this.parentFingerprint, + this.chainCode, this.path, this.index, this.depth, this.mnemonic, provider); + } + + get extendedKey(): string { + // We only support the mainnet values for now, but if anyone needs + // testnet values, let me know. I believe current sentiment is that + // we should always use mainnet, and use BIP-44 to derive the network + // - Mainnet: public=0x0488B21E, private=0x0488ADE4 + // - Testnet: public=0x043587CF, private=0x04358394 + + if (this.depth >= 256) { throw new Error("Depth too large!"); } + + return encodeBase58Check(concat([ + "0x0488ADE4", zpad(this.depth, 1), this.parentFingerprint, + zpad(this.index, 4), this.chainCode, + concat([ "0x00", this.privateKey ]) + ])); + } + + hasPath(): this is { path: string } { return (this.path != null); } + + neuter(): HDNodeVoidWallet { + return new HDNodeVoidWallet(_guard, this.address, this.publicKey, + this.parentFingerprint, this.chainCode, this.path, this.index, + this.depth, this.provider); + } + + deriveChild(_index: Numeric): HDNodeWallet { + const index = logger.getNumber(_index, "index"); + if (index > 0xffffffff) { throw new Error("invalid index - " + String(index)); } + + // Base path + let path = this.path; + if (path) { + path += "/" + (index & ~HardenedBit); + if (index & HardenedBit) { path += "'"; } + } + + const { IR, IL } = ser_I(index, this.chainCode, this.publicKey, this.privateKey); + const ki = new SigningKey(toHex((toBigInt(IL) + BigInt(this.privateKey)) % N, 32)); + + return new HDNodeWallet(_guard, ki, this.fingerprint, hexlify(IR), + path, index, this.depth + 1, this.mnemonic, this.provider); + + } + + derivePath(path: string): HDNodeWallet { + return derivePath(this, path); + } + + static #fromSeed(_seed: BytesLike, mnemonic: null | Mnemonic): HDNodeWallet { + const seed = logger.getBytes(_seed, "seed"); + if (seed.length < 16 || seed.length > 64) { + throw new Error("invalid seed"); + } + + const I = logger.getBytes(computeHmac("sha512", MasterSecret, seed)); + const signingKey = new SigningKey(hexlify(I.slice(0, 32))); + + return new HDNodeWallet(_guard, signingKey, "0x00000000", hexlify(I.slice(32)), + "m", 0, 0, mnemonic, null); + } + + static fromSeed(seed: BytesLike): HDNodeWallet { + return HDNodeWallet.#fromSeed(seed, null); + } + + static fromPhrase(phrase: string, password = "", path: null | string = defaultPath, wordlist: Wordlist = langEn): HDNodeWallet { + if (!path) { path = defaultPath; } + const mnemonic = Mnemonic.fromPhrase(phrase, password, wordlist) + return HDNodeWallet.#fromSeed(mnemonic.computeSeed(), mnemonic).derivePath(path); + } + + static fromMnemonic(mnemonic: Mnemonic, path: null | string = defaultPath): HDNodeWallet { + if (!path) { path = defaultPath; } + return HDNodeWallet.#fromSeed(mnemonic.computeSeed(), mnemonic).derivePath(path); + } + + static fromExtendedKey(extendedKey: string): HDNodeWallet | HDNodeVoidWallet { + const bytes = logger.getBytes(decodeBase58(extendedKey)); // @TODO: redact + + if (bytes.length !== 82 || encodeBase58Check(bytes.slice(0, 78)) !== extendedKey) { + logger.throwArgumentError("invalid extended key", "extendedKey", "[ REDACTED ]"); + } + + const depth = bytes[4]; + const parentFingerprint = hexlify(bytes.slice(5, 9)); + const index = parseInt(hexlify(bytes.slice(9, 13)).substring(2), 16); + const chainCode = hexlify(bytes.slice(13, 45)); + const key = bytes.slice(45, 78); + + switch (hexlify(bytes.slice(0, 4))) { + // Public Key + case "0x0488b21e": case "0x043587cf": { + const publicKey = hexlify(key); + return new HDNodeVoidWallet(_guard, computeAddress(publicKey), publicKey, + parentFingerprint, chainCode, null, index, depth, null); + } + + // Private Key + case "0x0488ade4": case "0x04358394 ": + if (key[0] !== 0) { break; } + return new HDNodeWallet(_guard, new SigningKey(key.slice(1)), + parentFingerprint, chainCode, null, index, depth, null, null); + } + + + return logger.throwArgumentError("invalid extended key prefix", "extendedKey", "[ REDACTED ]"); + } + + static createRandom(password = "", path: null | string = defaultPath, wordlist: Wordlist = langEn): HDNodeWallet { + if (!path) { path = defaultPath; } + const mnemonic = Mnemonic.fromEntropy(randomBytes(16), password, wordlist) + return HDNodeWallet.#fromSeed(mnemonic.computeSeed(), mnemonic).derivePath(path); + } +} + +export class HDNodeVoidWallet extends VoidSigner { + readonly publicKey!: string; + + readonly fingerprint!: string; + readonly parentFingerprint!: string; + + readonly chainCode!: string; + + readonly path!: null | string; + readonly index!: number; + readonly depth!: number; + + constructor(guard: any, address: string, publicKey: string, parentFingerprint: string, chainCode: string, path: null | string, index: number, depth: number, provider: null | Provider) { + super(address, provider); + logger.assertPrivate(guard, _guard, "HDNodeVoidWallet"); + + defineProperties(this, { publicKey }); + + const fingerprint = dataSlice(ripemd160(sha256(publicKey)), 0, 4); + defineProperties(this, { + publicKey, fingerprint, parentFingerprint, chainCode, path, index, depth + }); + } + + connect(provider: null | Provider): HDNodeVoidWallet { + return new HDNodeVoidWallet(_guard, this.address, this.publicKey, + this.parentFingerprint, this.chainCode, this.path, this.index, this.depth, provider); + } + + get extendedKey(): string { + // We only support the mainnet values for now, but if anyone needs + // testnet values, let me know. I believe current sentiment is that + // we should always use mainnet, and use BIP-44 to derive the network + // - Mainnet: public=0x0488B21E, private=0x0488ADE4 + // - Testnet: public=0x043587CF, private=0x04358394 + + if (this.depth >= 256) { throw new Error("Depth too large!"); } + + return encodeBase58Check(concat([ + "0x0488B21E", + zpad(this.depth, 1), + this.parentFingerprint, + zpad(this.index, 4), + this.chainCode, + this.publicKey, + ])); + } + + hasPath(): this is { path: string } { return (this.path != null); } + + deriveChild(_index: Numeric): HDNodeVoidWallet { + const index = logger.getNumber(_index, "index"); + if (index > 0xffffffff) { throw new Error("invalid index - " + String(index)); } + + // Base path + let path = this.path; + if (path) { + path += "/" + (index & ~HardenedBit); + if (index & HardenedBit) { path += "'"; } + } + + const { IR, IL } = ser_I(index, this.chainCode, this.publicKey, null); + const Ki = SigningKey._addPoints(IL, this.publicKey, true); + + const address = computeAddress(Ki); + + return new HDNodeVoidWallet(_guard, address, Ki, this.fingerprint, hexlify(IR), + path, index, this.depth + 1, this.provider); + + } + + derivePath(path: string): HDNodeVoidWallet { + return derivePath(this, path); + } +} + +export class HDNodeWalletManager { + #root: HDNodeWallet; + + constructor(phrase: string, password = "", path = "m/44'/60'/0'/0", locale: Wordlist = langEn) { + this.#root = HDNodeWallet.fromPhrase(phrase, password, path, locale); + } + + getSigner(index = 0): HDNodeWallet { + return this.#root.deriveChild(index); + } +} + +export function getAccountPath(_index: Numeric): string { + const index = logger.getNumber(_index, "index"); + if (index < 0 || index >= HardenedBit) { + logger.throwArgumentError("invalid account index", "index", index); + } + return `m/44'/60'/${ index }'/0/0`; +} + diff --git a/src.ts/wallet/index.ts b/src.ts/wallet/index.ts new file mode 100644 index 000000000..842a5e6db --- /dev/null +++ b/src.ts/wallet/index.ts @@ -0,0 +1,22 @@ +export { + defaultPath, + + getAccountPath, + + HDNodeWallet, + HDNodeVoidWallet, + HDNodeWalletManager, +} from "./hdwallet.js"; +export { isCrowdsaleJson, decryptCrowdsaleJson } from "./json-crowdsale.js"; +export { + isKeystoreJson, + decryptKeystoreJsonSync, decryptKeystoreJson, + encryptKeystoreJson +} from "./json-keystore.js"; +export { Mnemonic } from "./mnemonic.js"; +export { Wallet } from "./wallet.js"; + +export type { + KeystoreAccountParams, KeystoreAccount, + EncryptOptions +} from "./json-keystore.js" diff --git a/src.ts/wallet/json-crowdsale.ts b/src.ts/wallet/json-crowdsale.ts new file mode 100644 index 000000000..23c3c7376 --- /dev/null +++ b/src.ts/wallet/json-crowdsale.ts @@ -0,0 +1,54 @@ +import { CBC, pkcs7Strip } from "aes-js"; + +import { getAddress } from "../address/index.js"; +import { pbkdf2 } from "../crypto/index.js"; +import { id } from "../hash/id.js"; +import { logger } from "../utils/index.js"; + +import { getPassword, looseArrayify, spelunk } from "./utils.js"; + + +export interface CrowdsaleAccount { + privateKey: string; + address: string; +} + +export function isCrowdsaleJson(json: string): boolean { + try { + const data = JSON.parse(json); + if (data.encseed) { return true; } + } catch (error) { } + return false; +} + +// See: https://github.com/ethereum/pyethsaletool +export function decryptCrowdsaleJson(json: string, _password: string | Uint8Array): CrowdsaleAccount { + const data = JSON.parse(json); + const password = getPassword(_password); + + // Ethereum Address + const address = getAddress(spelunk(data, "ethaddr:string!")); + + // Encrypted Seed + const encseed = looseArrayify(spelunk(data, "encseed:string!")); + if (!encseed || (encseed.length % 16) !== 0) { + logger.throwArgumentError("invalid encseed", "json", json); + } + + const key = logger.getBytes(pbkdf2(password, password, 2000, 32, "sha256")).slice(0, 16); + + const iv = encseed.slice(0, 16); + const encryptedSeed = encseed.slice(16); + + // Decrypt the seed + const aesCbc = new CBC(key, iv); + const seed = pkcs7Strip(logger.getBytes(aesCbc.decrypt(encryptedSeed))); + + // This wallet format is weird... Convert the binary encoded hex to a string. + let seedHex = ""; + for (let i = 0; i < seed.length; i++) { + seedHex += String.fromCharCode(seed[i]); + } + + return { address, privateKey: id(seedHex) }; +} diff --git a/src.ts/wallet/json-keystore.ts b/src.ts/wallet/json-keystore.ts new file mode 100644 index 000000000..1f9f19775 --- /dev/null +++ b/src.ts/wallet/json-keystore.ts @@ -0,0 +1,367 @@ +import { CTR } from "aes-js"; + +import { getAddress } from "../address/index.js"; +import { keccak256, pbkdf2, randomBytes, scrypt, scryptSync } from "../crypto/index.js"; +import { computeAddress } from "../transaction/index.js"; +import { concat, hexlify, logger } from "../utils/index.js"; + +import { getPassword, spelunk, uuidV4, zpad } from "./utils.js"; + +import type { ProgressCallback } from "../crypto/index.js"; +import type { BytesLike } from "../utils/index.js"; + +import { version } from "../_version.js"; + +const defaultPath = "m/44'/60'/0'/0/0"; + + +export type KeystoreAccountParams = { + privateKey: string; + address?: string; + mnemonic?: { + entropy: string; + path: string; + locale: string; + }; +}; + +export type KeystoreAccount = { + address: string; + privateKey: string; + mnemonic?: { + entropy: string; + path: string; + locale: string; + }; +}; + +export type EncryptOptions = { + iv?: BytesLike; + entropy?: BytesLike; + client?: string; + salt?: BytesLike; + uuid?: string; + scrypt?: { + N?: number; + r?: number; + p?: number; + } +} + +export function isKeystoreJson(json: string): boolean { + try { + const data = JSON.parse(json); + const version = ((data.version != null) ? parseInt(data.version): 0); + if (version === 3) { return true; } + } catch (error) { } + return false; +} + +function decrypt(data: any, key: Uint8Array, ciphertext: Uint8Array): string { + const cipher = spelunk(data, "crypto.cipher:string"); + if (cipher === "aes-128-ctr") { + const iv = spelunk(data, "crypto.cipherparams.iv:data!") + const aesCtr = new CTR(key, iv); + return hexlify(aesCtr.decrypt(ciphertext)); + } + + return logger.throwError("unsupported cipher", "UNSUPPORTED_OPERATION", { + operation: "decrypt" + }); +} + +function getAccount(data: any, _key: string): KeystoreAccount { + const key = logger.getBytes(_key); + const ciphertext = spelunk(data, "crypto.ciphertext:data!"); + + const computedMAC = hexlify(keccak256(concat([ key.slice(16, 32), ciphertext ]))).substring(2); + if (computedMAC !== spelunk(data, "crypto.mac:string!").toLowerCase()) { + return logger.throwArgumentError("incorrect password", "password", "[ REDACTED ]"); + } + + const privateKey = decrypt(data, key.slice(0, 16), ciphertext); + + const address = computeAddress(privateKey); + if (data.address) { + let check = data.address.toLowerCase(); + if (check.substring(0, 2) !== "0x") { check = "0x" + check; } + + if (getAddress(check) !== address) { + logger.throwArgumentError("keystore address/privateKey mismatch", "address", data.address); + } + } + + const account: KeystoreAccount = { address, privateKey }; + + // Version 0.1 x-ethers metadata must contain an encrypted mnemonic phrase + const version = spelunk(data, "x-ethers.version:string"); + if (version === "0.1") { + const mnemonicKey = key.slice(32, 64); + + const mnemonicCiphertext = spelunk(data, "x-ethers.mnemonicCiphertext:data!"); + const mnemonicIv = spelunk(data, "x-ethers.mnemonicCounter:data!"); + + const mnemonicAesCtr = new CTR(mnemonicKey, mnemonicIv); + + account.mnemonic = { + path: (spelunk(data, "x-ethers.path:string") || defaultPath), + locale: (spelunk(data, "x-ethers.locale:string") || "en"), + entropy: hexlify(logger.getBytes(mnemonicAesCtr.decrypt(mnemonicCiphertext))) + }; + } + + return account; +} + +type KdfParams = { + name: "scrypt"; + salt: Uint8Array; + N: number; + r: number; + p: number; + dkLen: number; +} | { + name: "pbkdf2"; + salt: Uint8Array; + count: number; + dkLen: number; + algorithm: "sha256" | "sha512"; +}; + +function getKdfParams(data: any): KdfParams { + const kdf = spelunk(data, "crypto.kdf:string"); + if (kdf && typeof(kdf) === "string") { + const throwError = function(name: string, value: any): never { + return logger.throwArgumentError("invalid key-derivation function parameters", name, value); + } + + if (kdf.toLowerCase() === "scrypt") { + const salt = spelunk(data, "crypto.kdfparams.salt:data!"); + const N = spelunk(data, "crypto.kdfparams.n:int!"); + const r = spelunk(data, "crypto.kdfparams.r:int!"); + const p = spelunk(data, "crypto.kdfparams.p:int!"); + + // Check for all required parameters + if (!N || !r || !p) { return throwError("kdf", kdf); } + + // Make sure N is a power of 2 + if ((N & (N - 1)) !== 0) { return throwError("N", N); } + + const dkLen = spelunk(data, "crypto.kdfparams.dklen:int!"); + if (dkLen !== 32) { return throwError("dklen", dkLen); } + + return { name: "scrypt", salt, N, r, p, dkLen: 64 }; + + } else if (kdf.toLowerCase() === "pbkdf2") { + + const salt = spelunk(data, "crypto.kdfparams.salt:data!"); + + const prf = spelunk(data, "crypto.kdfparams.prf:string!"); + const algorithm = prf.split("-").pop(); + if (algorithm !== "sha256" && algorithm !== "sha512") { + return throwError("prf", prf); + } + + const count = spelunk(data, "crypto.kdfparams.c:int!"); + + const dkLen = spelunk(data, "crypto.kdfparams.dklen:int!"); + if (dkLen !== 32) { throwError("dklen", dkLen); } + + return { name: "pbkdf2", salt, count, dkLen, algorithm }; + } + } + + return logger.throwArgumentError("unsupported key-derivation function", "kdf", kdf); +} + + +export function decryptKeystoreJsonSync(json: string, _password: string | Uint8Array): KeystoreAccount { + const data = JSON.parse(json); + + const password = getPassword(_password); + + const params = getKdfParams(data); + if (params.name === "pbkdf2") { + const { salt, count, dkLen, algorithm } = params; + const key = pbkdf2(password, salt, count, dkLen, algorithm); + return getAccount(data, key); + } else if (params.name === "scrypt") { + const { salt, N, r, p, dkLen } = params; + const key = scryptSync(password, salt, N, r, p, dkLen); + return getAccount(data, key); + } + + throw new Error("unreachable"); +} + +function stall(duration: number): Promise { + return new Promise((resolve) => { setTimeout(() => { resolve(); }, duration); }); +} + +export async function decryptKeystoreJson(json: string, _password: string | Uint8Array, progress?: ProgressCallback): Promise { + const data = JSON.parse(json); + + const password = getPassword(_password); + + const params = getKdfParams(data); + if (params.name === "pbkdf2") { + if (progress) { + progress(0); + await stall(0); + } + const { salt, count, dkLen, algorithm } = params; + const key = pbkdf2(password, salt, count, dkLen, algorithm); + if (progress) { + progress(1); + await stall(0); + } + return getAccount(data, key); + } else if (params.name === "scrypt") { + const { salt, N, r, p, dkLen } = params; + const key = await scrypt(password, salt, N, r, p, dkLen, progress); + return getAccount(data, key); + } + + throw new Error("unreachable"); +} + + +export async function encryptKeystoreJson(account: KeystoreAccount, password: string | Uint8Array, options?: EncryptOptions, progressCallback?: ProgressCallback): Promise { + + // Check the address matches the private key + //if (getAddress(account.address) !== computeAddress(account.privateKey)) { + // throw new Error("address/privateKey mismatch"); + //} + + // Check the mnemonic (if any) matches the private key + /* + if (hasMnemonic(account)) { + const mnemonic = account.mnemonic; + const node = HDNode.fromMnemonic(mnemonic.phrase, null, mnemonic.locale).derivePath(mnemonic.path || defaultPath); + + if (node.privateKey != account.privateKey) { + throw new Error("mnemonic mismatch"); + } + } + */ + + // The options are optional, so adjust the call as needed + if (typeof(options) === "function" && !progressCallback) { + progressCallback = options; + options = {}; + } + if (!options) { options = {}; } + + const privateKey = logger.getBytes(account.privateKey, "privateKey"); + const passwordBytes = getPassword(password); + +/* + let mnemonic: null | Mnemonic = null; + let entropy: Uint8Array = null + let path: string = null; + let locale: string = null; + if (hasMnemonic(account)) { + const srcMnemonic = account.mnemonic; + entropy = arrayify(mnemonicToEntropy(srcMnemonic.phrase, srcMnemonic.locale || "en")); + path = srcMnemonic.path || defaultPath; + locale = srcMnemonic.locale || "en"; + mnemonic = Mnemonic.from( + } +*/ + // Check/generate the salt + const salt = (options.salt != null) ? logger.getBytes(options.salt, "options.slat"): randomBytes(32); + + // Override initialization vector + const iv = (options.iv != null) ? logger.getBytes(options.iv, "options.iv"): randomBytes(16); + if (iv.length !== 16) { + logger.throwArgumentError("invalid options.iv", "options.iv", options.iv); + } + + // Override the uuid + const uuidRandom = (options.uuid != null) ? logger.getBytes(options.uuid, "options.uuid"): randomBytes(16); + if (uuidRandom.length !== 16) { + logger.throwArgumentError("invalid options.uuid", "options.uuid", options.iv); + } + if (uuidRandom.length !== 16) { throw new Error("invalid uuid"); } + + // Override the scrypt password-based key derivation function parameters + let N = (1 << 17), r = 8, p = 1; + if (options.scrypt) { + if (options.scrypt.N) { N = options.scrypt.N; } + if (options.scrypt.r) { r = options.scrypt.r; } + if (options.scrypt.p) { p = options.scrypt.p; } + } + + // We take 64 bytes: + // - 32 bytes As normal for the Web3 secret storage (derivedKey, macPrefix) + // - 32 bytes AES key to encrypt mnemonic with (required here to be Ethers Wallet) + const _key = await scrypt(passwordBytes, salt, N, r, p, 64, progressCallback); + const key = logger.getBytes(_key); + + // This will be used to encrypt the wallet (as per Web3 secret storage) + const derivedKey = key.slice(0, 16); + const macPrefix = key.slice(16, 32); + + // Encrypt the private key + const aesCtr = new CTR(derivedKey, iv); + const ciphertext = logger.getBytes(aesCtr.encrypt(privateKey)); + + // Compute the message authentication code, used to check the password + const mac = keccak256(concat([ macPrefix, ciphertext ])) + + // See: https://github.com/ethereum/wiki/wiki/Web3-Secret-Storage-Definition + const data: { [key: string]: any } = { + address: account.address.substring(2).toLowerCase(), + id: uuidV4(uuidRandom), + version: 3, + Crypto: { + cipher: "aes-128-ctr", + cipherparams: { + iv: hexlify(iv).substring(2), + }, + ciphertext: hexlify(ciphertext).substring(2), + kdf: "scrypt", + kdfparams: { + salt: hexlify(salt).substring(2), + n: N, + dklen: 32, + p: p, + r: r + }, + mac: mac.substring(2) + } + }; + + // If we have a mnemonic, encrypt it into the JSON wallet + if (account.mnemonic) { + const client = (options.client != null) ? options.client: `ethers/${ version }`; + + const path = account.mnemonic.path || defaultPath; + const locale = account.mnemonic.locale || "en"; + + const mnemonicKey = key.slice(32, 64); + + const entropy = logger.getBytes(account.mnemonic.entropy, "account.mnemonic.entropy"); + const mnemonicIv = randomBytes(16); + const mnemonicAesCtr = new CTR(mnemonicKey, mnemonicIv); + const mnemonicCiphertext = logger.getBytes(mnemonicAesCtr.encrypt(entropy)); + + const now = new Date(); + const timestamp = (now.getUTCFullYear() + "-" + + zpad(now.getUTCMonth() + 1, 2) + "-" + + zpad(now.getUTCDate(), 2) + "T" + + zpad(now.getUTCHours(), 2) + "-" + + zpad(now.getUTCMinutes(), 2) + "-" + + zpad(now.getUTCSeconds(), 2) + ".0Z"); + const gethFilename = ("UTC--" + timestamp + "--" + data.address); + + data["x-ethers"] = { + client, gethFilename, path, locale, + mnemonicCounter: hexlify(mnemonicIv).substring(2), + mnemonicCiphertext: hexlify(mnemonicCiphertext).substring(2), + version: "0.1" + }; + } + + return JSON.stringify(data); +} diff --git a/src.ts/wallet/mnemonic.ts b/src.ts/wallet/mnemonic.ts new file mode 100644 index 000000000..9a502da27 --- /dev/null +++ b/src.ts/wallet/mnemonic.ts @@ -0,0 +1,154 @@ +import { pbkdf2, sha256 } from "../crypto/index.js"; +import { defineProperties, hexlify, logger, toUtf8Bytes } from "../utils/index.js"; +import { langEn } from "../wordlists/lang-en.js"; + +import type { BytesLike } from "../utils/index.js"; +import type { Wordlist } from "../wordlists/index.js"; + + +// Returns a byte with the MSB bits set +function getUpperMask(bits: number): number { + return ((1 << bits) - 1) << (8 - bits) & 0xff; +} + +// Returns a byte with the LSB bits set +function getLowerMask(bits: number): number { + return ((1 << bits) - 1) & 0xff; +} + + +function mnemonicToEntropy(mnemonic: string, wordlist: null | Wordlist = langEn): string { + logger.assertNormalize("NFKD"); + + if (wordlist == null) { wordlist = langEn; } + + const words = wordlist.split(mnemonic); + if ((words.length % 3) !== 0 || words.length < 12 || words.length > 24) { + logger.throwArgumentError("invalid mnemonic length", "mnemonic", "[ REDACTED ]"); + } + + const entropy = new Uint8Array(Math.ceil(11 * words.length / 8)); + + let offset = 0; + for (let i = 0; i < words.length; i++) { + let index = wordlist.getWordIndex(words[i].normalize("NFKD")); + if (index === -1) { + logger.throwArgumentError(`invalid mnemonic word at index ${ i }`, "mnemonic", "[ REDACTED ]"); + } + + for (let bit = 0; bit < 11; bit++) { + if (index & (1 << (10 - bit))) { + entropy[offset >> 3] |= (1 << (7 - (offset % 8))); + } + offset++; + } + } + + const entropyBits = 32 * words.length / 3; + + + const checksumBits = words.length / 3; + const checksumMask = getUpperMask(checksumBits); + + const checksum = logger.getBytes(sha256(entropy.slice(0, entropyBits / 8)))[0] & checksumMask; + + if (checksum !== (entropy[entropy.length - 1] & checksumMask)) { + logger.throwArgumentError("invalid mnemonic checksum", "mnemonic", "[ REDACTED ]"); + } + + return hexlify(entropy.slice(0, entropyBits / 8)); +} + +function entropyToMnemonic(entropy: Uint8Array, wordlist: null | Wordlist = langEn): string { + if ((entropy.length % 4) || entropy.length < 16 || entropy.length > 32) { + logger.throwArgumentError("invalid entropy size", "entropy", "[ REDACTED ]"); + } + + if (wordlist == null) { wordlist = langEn; } + + const indices: Array = [ 0 ]; + + let remainingBits = 11; + for (let i = 0; i < entropy.length; i++) { + + // Consume the whole byte (with still more to go) + if (remainingBits > 8) { + indices[indices.length - 1] <<= 8; + indices[indices.length - 1] |= entropy[i]; + + remainingBits -= 8; + + // This byte will complete an 11-bit index + } else { + indices[indices.length - 1] <<= remainingBits; + indices[indices.length - 1] |= entropy[i] >> (8 - remainingBits); + + // Start the next word + indices.push(entropy[i] & getLowerMask(8 - remainingBits)); + + remainingBits += 3; + } + } + + // Compute the checksum bits + const checksumBits = entropy.length / 4; + const checksum = parseInt(sha256(entropy).substring(2, 4), 16) & getUpperMask(checksumBits); + + // Shift the checksum into the word indices + indices[indices.length - 1] <<= checksumBits; + indices[indices.length - 1] |= (checksum >> (8 - checksumBits)); + + return wordlist.join(indices.map((index) => (wordlist).getWord(index))); +} + +const _guard = { }; + +export class Mnemonic { + readonly phrase!: string; + readonly password!: string; + readonly wordlist!: Wordlist; + + readonly entropy!: string; + + constructor(guard: any, entropy: string, phrase: string, password?: null | string, wordlist?: null | Wordlist) { + if (password == null) { password = ""; } + if (wordlist == null) { wordlist = langEn; } + logger.assertPrivate(guard, _guard, "Mnemonic"); + defineProperties(this, { phrase, password, wordlist, entropy }); + } + + computeSeed(): string { + const salt = toUtf8Bytes("mnemonic" + this.password, "NFKD"); + return pbkdf2(toUtf8Bytes(this.phrase, "NFKD"), salt, 2048, 64, "sha512"); + } + + static fromPhrase(phrase: string, password?: null | string, wordlist?: null | Wordlist) { + // Normalize the case and space; throws if invalid + const entropy = mnemonicToEntropy(phrase, wordlist); + phrase = entropyToMnemonic(logger.getBytes(entropy), wordlist); + return new Mnemonic(_guard, entropy, phrase, password, wordlist); + } + + static fromEntropy(_entropy: BytesLike, password?: null | string, wordlist?: null | Wordlist): Mnemonic { + const entropy = logger.getBytes(_entropy, "entropy"); + const phrase = entropyToMnemonic(entropy, wordlist); + return new Mnemonic(_guard, hexlify(entropy), phrase, password, wordlist); + } + + static entropyToPhrase(_entropy: BytesLike, wordlist?: null | Wordlist): string { + const entropy = logger.getBytes(_entropy, "entropy"); + return entropyToMnemonic(entropy, wordlist); + } + + static phraseToEntropy(phrase: string, wordlist?: null | Wordlist): string { + return mnemonicToEntropy(phrase, wordlist); + } + + static isValidMnemonic(phrase: string, wordlist?: null | Wordlist): boolean { + try { + mnemonicToEntropy(phrase, wordlist); + return true; + } catch (error) { } + return false; + } +} diff --git a/src.ts/wallet/utils.ts b/src.ts/wallet/utils.ts new file mode 100644 index 000000000..e2258dfea --- /dev/null +++ b/src.ts/wallet/utils.ts @@ -0,0 +1,146 @@ +import { hexlify, logger, toUtf8Bytes } from "../utils/index.js"; + +import type { BytesLike } from "../utils/index.js"; + + +export function looseArrayify(hexString: string): Uint8Array { + if (typeof(hexString) === 'string' && hexString.substring(0, 2) !== '0x') { + hexString = '0x' + hexString; + } + return logger.getBytesCopy(hexString); +} + +export function zpad(value: String | number, length: number): String { + value = String(value); + while (value.length < length) { value = '0' + value; } + return value; +} + +export function getPassword(password: string | Uint8Array): Uint8Array { + if (typeof(password) === 'string') { + return toUtf8Bytes(password, "NFKC"); + } + return logger.getBytesCopy(password); +} + +export function spelunk(object: any, _path: string): T { + + const match = _path.match(/^([a-z0-9$_.-]*)(:([a-z]+))?(!)?$/i); + if (match == null) { + return logger.throwArgumentError("invalid path", "path", _path); + } + const path = match[1]; + const type = match[3]; + const reqd = (match[4] === "!"); + + let cur = object; + for (const comp of path.toLowerCase().split('.')) { + + // Search for a child object with a case-insensitive matching key + if (Array.isArray(cur)) { + if (!comp.match(/^[0-9]+$/)) { break; } + cur = cur[parseInt(comp)]; + + } else if (typeof(cur) === "object") { + let found: any = null; + for (const key in cur) { + if (key.toLowerCase() === comp) { + found = cur[key]; + break; + } + } + cur = found; + + } else { + cur = null; + } + + if (cur == null) { break; } + } + + if (reqd && cur == null) { + logger.throwArgumentError("missing required value", "path", path); + } + + if (type && cur != null) { + if (type === "int") { + if (typeof(cur) === "string" && cur.match(/^-?[0-9]+$/)) { + return parseInt(cur); + } else if (Number.isSafeInteger(cur)) { + return cur; + } + } + + if (type === "number") { + if (typeof(cur) === "string" && cur.match(/^-?[0-9.]*$/)) { + return parseFloat(cur); + } + } + + if (type === "data") { + if (typeof(cur) === "string") { return looseArrayify(cur); } + } + + if (type === "array" && Array.isArray(cur)) { return cur; } + if (type === typeof(cur)) { return cur; } + + logger.throwArgumentError(`wrong type found for ${ type } `, "path", path); + } + + return cur; +} +/* +export function follow(object: any, path: string): null | string { + let currentChild = object; + + for (const comp of path.toLowerCase().split('/')) { + + // Search for a child object with a case-insensitive matching key + let matchingChild = null; + for (const key in currentChild) { + if (key.toLowerCase() === comp) { + matchingChild = currentChild[key]; + break; + } + } + + if (matchingChild === null) { return null; } + + currentChild = matchingChild; + } + + return currentChild; +} + +// "path/to/something:type!" +export function followRequired(data: any, path: string): string { + const value = follow(data, path); + if (value != null) { return value; } + return logger.throwArgumentError("invalid value", `data:${ path }`, + JSON.stringify(data)); +} +*/ +// See: https://www.ietf.org/rfc/rfc4122.txt (Section 4.4) +export function uuidV4(randomBytes: BytesLike): string { + const bytes = logger.getBytes(randomBytes, "randomBytes"); + + // Section: 4.1.3: + // - time_hi_and_version[12:16] = 0b0100 + bytes[6] = (bytes[6] & 0x0f) | 0x40; + + // Section 4.4 + // - clock_seq_hi_and_reserved[6] = 0b0 + // - clock_seq_hi_and_reserved[7] = 0b1 + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const value = hexlify(bytes); + + return [ + value.substring(2, 10), + value.substring(10, 14), + value.substring(14, 18), + value.substring(18, 22), + value.substring(22, 34), + ].join("-"); +} + diff --git a/src.ts/wallet/wallet.ts b/src.ts/wallet/wallet.ts new file mode 100644 index 000000000..c9466460d --- /dev/null +++ b/src.ts/wallet/wallet.ts @@ -0,0 +1,166 @@ +import { randomBytes, SigningKey } from "../crypto/index.js"; +import { computeAddress } from "../transaction/index.js"; +import { isHexString, logger } from "../utils/index.js"; + +import { BaseWallet } from "./base-wallet.js"; +import { HDNodeWallet } from "./hdwallet.js"; +import { decryptCrowdsaleJson, isCrowdsaleJson } from "./json-crowdsale.js"; +import { + decryptKeystoreJson, decryptKeystoreJsonSync, isKeystoreJson +} from "./json-keystore.js"; +import { Mnemonic } from "./mnemonic.js"; + +import type { ProgressCallback } from "../crypto/index.js"; +import type { Provider } from "../providers/index.js"; +import type { Wordlist } from "../wordlists/index.js"; + +import type { CrowdsaleAccount } from "./json-crowdsale.js"; +import type { KeystoreAccount } from "./json-keystore.js"; + + +function tryWallet(value: any): null | Wallet { + try { + if (!value || !value.signingKey) { return null; } + const key = trySigningKey(value.signingKey); + if (key == null || computeAddress(key.publicKey) !== value.address) { return null; } + if (value.mnemonic) { + const wallet = HDNodeWallet.fromMnemonic(value.mnemonic); + if (wallet.privateKey !== key.privateKey) { return null; } + } + return value; + } catch (e) { console.log(e); } + return null; +} + +// Try using value as mnemonic to derive the defaultPath HDodeWallet +function tryMnemonic(value: any): null | HDNodeWallet { + try { + if (value == null || typeof(value.phrase) !== "string" || + typeof(value.password) !== "string" || + value.wordlist == null) { return null; } + return HDNodeWallet.fromPhrase(value.phrase, value.password, null, value.wordlist); + } catch (error) { console.log(error); } + return null; +} + +function trySigningKey(value: any): null | SigningKey { + try { + if (!value || !isHexString(value.privateKey, 32)) { return null; } + + const key = value.privateKey; + if (SigningKey.computePublicKey(key) !== value.publicKey) { return null; } + return new SigningKey(key); + } catch (e) { console.log(e); } + return null; +} + +function stall(duration: number): Promise { + return new Promise((resolve) => { setTimeout(() => { resolve(); }, duration); }); +} + +export class Wallet extends BaseWallet { + readonly #mnemonic: null | Mnemonic; + + constructor(key: string | Mnemonic | SigningKey | BaseWallet, provider?: null | Provider) { + let signingKey: null | SigningKey = null; + let mnemonic: null | Mnemonic = null; + + // A normal private key + if (typeof(key) === "string") { signingKey = new SigningKey(key); } + + // Try Wallet + if (signingKey == null) { + const wallet = tryWallet(key); + if (wallet) { + signingKey = wallet.signingKey; + mnemonic = wallet.mnemonic || null; + } + } + + // Try Mnemonic, with the defaultPath wallet + if (signingKey == null) { + const wallet = tryMnemonic(key); + if (wallet) { + signingKey = wallet.signingKey; + mnemonic = wallet.mnemonic || null; + } + } + + // A signing key + if (signingKey == null) { signingKey = trySigningKey(key); } + + if (signingKey == null) { + logger.throwArgumentError("invalid key", "key", "[ REDACTED ]"); + } + + super(signingKey as SigningKey, provider); + this.#mnemonic = mnemonic; + } + + // Store this in a getter to reduce visibility in console.log + get mnemonic(): null | Mnemonic { return this.#mnemonic; } + + connect(provider: null | Provider): Wallet { + return new Wallet(this, provider); + } + + async encrypt(password: Uint8Array | string, options?: any, progressCallback?: ProgressCallback): Promise { + throw new Error("TODO"); + } + + encryptSync(password: Uint8Array | string, options?: any): Promise { + throw new Error("TODO"); + } + + static async fromEncryptedJson(json: string, password: Uint8Array | string, progress?: ProgressCallback): Promise { + let account: null | CrowdsaleAccount | KeystoreAccount = null; + if (isKeystoreJson(json)) { + account = await decryptKeystoreJson(json, password, progress); + + } else if (isCrowdsaleJson(json)) { + if (progress) { progress(0); await stall(0); } + account = decryptCrowdsaleJson(json, password); + if (progress) { progress(1); await stall(0); } + + } else { + return logger.throwArgumentError("invalid JSON wallet", "json", "[ REDACTED ]"); + } + + const wallet = new Wallet(account.privateKey); + if (wallet.address !== account.address) { + logger.throwArgumentError("address/privateKey mismatch", "json", "[ REDACTED ]"); + } + // @TODO: mnemonic + return wallet; + } + + static fromEncryptedJsonSync(json: string, password: Uint8Array | string): Wallet { + let account: null | CrowdsaleAccount | KeystoreAccount = null; + if (isKeystoreJson(json)) { + account = decryptKeystoreJsonSync(json, password); + } else if (isCrowdsaleJson(json)) { + account = decryptCrowdsaleJson(json, password); + } else { + return logger.throwArgumentError("invalid JSON wallet", "json", "[ REDACTED ]"); + } + + const wallet = new Wallet(account.privateKey); + if (wallet.address !== account.address) { + logger.throwArgumentError("address/privateKey mismatch", "json", "[ REDACTED ]"); + } + // @TODO: mnemonic + return wallet; + } + + static createRandom(provider?: null | Provider, password?: null | string, wordlist?: null | Wordlist): Wallet { + return new Wallet(Mnemonic.fromEntropy(randomBytes(16), password, wordlist), provider); + } + + static fromMnemonic(mnemonic: Mnemonic, provider?: null | Provider): Wallet { + return new Wallet(mnemonic, provider); + } + + static fromPhrase(phrase: string, provider?: null | Provider, password = "", wordlist?: Wordlist): Wallet { + return new Wallet(Mnemonic.fromPhrase(phrase, password, wordlist), provider); + } +} diff --git a/src.ts/wordlists/bit-reader.ts b/src.ts/wordlists/bit-reader.ts new file mode 100644 index 000000000..cc316adca --- /dev/null +++ b/src.ts/wordlists/bit-reader.ts @@ -0,0 +1,32 @@ +const Base64 = ")!@#$%^&*(ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"; + +export function decodeBits(width: number, data: string): Array { + const maxValue = (1 << width) - 1; + const result: Array = [ ]; + let accum = 0, bits = 0, flood = 0; + for (let i = 0; i < data.length; i++) { + + // Accumulate 6 bits of data + accum = ((accum << 6) | Base64.indexOf(data[i])); + bits += 6; + + // While we have enough for a word... + while (bits >= width) { + // ...read the word + const value = (accum >> (bits - width)); + accum &= (1 << (bits - width)) - 1; + bits -= width; + + // A value of 0 indicates we exceeded maxValue, it + // floods over into the next value + if (value === 0) { + flood += maxValue; + } else { + result.push(value + flood); + flood = 0; + } + } + } + + return result; +} diff --git a/src.ts/wordlists/decode-owl.ts b/src.ts/wordlists/decode-owl.ts new file mode 100644 index 000000000..cfdc9a284 --- /dev/null +++ b/src.ts/wordlists/decode-owl.ts @@ -0,0 +1,52 @@ +import { assertArgument } from "../utils/logger.js"; + + +const subsChrs = " !#$%&'()*+,-./<=>?@[]^_`{|}~"; +const Word = /^[a-z]*$/i; + +function unfold(words: Array, sep: string): Array { + let initial = 97; + return words.reduce((accum, word) => { + if (word === sep) { + initial++; + } else if (word.match(Word)) { + accum.push(String.fromCharCode(initial) + word); + } else { + initial = 97; + accum.push(word); + } + return accum; + }, >[]); +} + +export function decode(data: string, subs: string): Array { + + // Replace all the substitutions with their expanded form + for (let i = subsChrs.length - 1; i >= 0; i--) { + data = data.split(subsChrs[i]).join(subs.substring(2 * i, 2 * i + 2)); + } + + // Get all tle clumps; each suffix, first-increment and second-increment + const clumps: Array = [ ]; + const leftover = data.replace(/(:|([0-9])|([A-Z][a-z]*))/g, (all, item, semi, word) => { + if (semi) { + for (let i = parseInt(semi); i >= 0; i--) { clumps.push(";"); } + } else { + clumps.push(item.toLowerCase()); + } + return ""; + }); + /* c8 ignore start */ + if (leftover) { throw new Error(`leftovers: ${ JSON.stringify(leftover) }`); } + /* c8 ignore stop */ + + return unfold(unfold(clumps, ";"), ":"); +} + +export function decodeOwl(data: string): Array { + assertArgument(data[0] === "0", "unsupported auwl data", "data", data); + + return decode( + data.substring(1 + 2 * subsChrs.length), + data.substring(1, 1 + 2 * subsChrs.length)); +} diff --git a/src.ts/wordlists/decode-owla.ts b/src.ts/wordlists/decode-owla.ts new file mode 100644 index 000000000..cb6424fde --- /dev/null +++ b/src.ts/wordlists/decode-owla.ts @@ -0,0 +1,30 @@ +import { assertArgument } from "../utils/logger.js"; + +import { decodeBits } from "./bit-reader.js"; +import { decodeOwl } from "./decode-owl.js"; + +export function decodeOwlA(data: string, accents: string): Array { + let words = decodeOwl(data).join(","); + + // Inject the accents + accents.split(/,/g).forEach((accent) => { + + const match = accent.match(/^([a-z]*)([0-9]+)([0-9])(.*)$/); + assertArgument(match !== null, "internal error parsing accents", "accents", accents); + + let posOffset = 0; + const positions = decodeBits(parseInt(match[3]), match[4]); + const charCode = parseInt(match[2]); + const regex = new RegExp(`([${ match[1] }])`, "g"); + words = words.replace(regex, (all, letter) => { + const rem = --positions[posOffset]; + if (rem === 0) { + letter = String.fromCharCode(letter.charCodeAt(0), charCode); + posOffset++; + } + return letter; + }); + }); + + return words.split(","); +} diff --git a/src.ts/wordlists/generation/encode-latin.ts b/src.ts/wordlists/generation/encode-latin.ts new file mode 100644 index 000000000..6777042f4 --- /dev/null +++ b/src.ts/wordlists/generation/encode-latin.ts @@ -0,0 +1,370 @@ + +// OWL Data Format +// +// The Official WordList data format exported by this encoder +// encodes sorted latin-1 words (letters only) based on the +// fact that sorted words have prefixes with substantial +// overlap. +// +// For example, the words: +// [ Another, Apple, Apricot, Bread ] +// could be folded once with a single special character, such +// as ":" to yield: +// [ nother, pple, pricot, :, read ]. +// The First letter has been removed, but can be inferred by +// starting at A and incrementing to the next letter when ":" +// is encountered. +// +// The fold operation can be repeated for large sets as even within +// each folded set, there is substatial overlap in prefix. With the +// second special symbol ";", we get: +// [ ; x 13, other, :, ple, ricot, :, ; x 18, ead ] +// which can be further compressed by using numbers instead of the +// special character: +// [ 13, other, :, ple, ricot, :, 18, ead ] +// and to keep all values within a single byte, we only allow a +// maximum value of 10 (using 0 through 9 to represent 1 through 10), +// we get: +// [ 9, 2, other, :, ple, ricot, :, 9, 7, ead ] +// and we use camel-case to imply the bounrary, giving the final string: +// "92Other:PleRicot:97Ead" +// +// Once the entire latin-1 set has been collapsed, we use the remaining +// printable characters (except " and \, which require 2 bytes to represent +// in string) to substiture for the most common 2-letter pairs of letters +// in the string. +// +// OWLA Accent Format +// +// OWLA first removes all accents, and encodes that data using the OWL +// data format and encodes the accents as a base-64 series of 6-bit +// packed bits representing the distance from one followed letter to the +// next. +// +// For example, the acute accent in a given language may follow either +// a or e, in which case the follow-set is "ae". Each letter in the entire +// set is indexed, so the set of words with the accents: +// "thisA/ppleDoe/sNotMa/tterToMe/" +// " 1^ 2^ 3^ 4^ 5^ 6^ " <-- follow-set members, ALL a's and e's +// which gives the positions: +// [ 0, 2, 3, 4, 6 ] +// which then reduce to the distances +// [ 0, 2, 1, 1, 2 ] +// each of which fit into a 2-bit value, so this can be encoded as the +// base-64 encoded string: +// 00 10 01 01 10 = 001001 1010xx +// +// The base-64 set used has all number replaced with their +// shifted-counterparts to prevent comflicting with the numbers used in +// the fold operation to indicate the number of ";". + +import fs from "fs"; + +import { id } from "../../hash/id.js"; + +import { decodeOwl } from "../decode-owl.js"; +import { decodeOwlA } from "../decode-owla.js"; + +const subsChrs = " !#$%&'()*+,-./<=>?@[]^_`{|}~"; + +const Word = /^[a-z'`]*$/i; + +function fold(words: Array, sep: string): Array { + const output: Array = [ ]; + + let initial = 97; + for (const word of words) { + if (word.match(Word)) { + while (initial < word.charCodeAt(0)) { + initial++; + output.push(sep); + } + output.push(word.substring(1)); + } else { + initial = 97; + output.push(word); + } + } + + return output; +} + +function camelcase(words: Array): string { + return words.map((word) => { + if (word.match(Word)) { + return word[0].toUpperCase() + word.substring(1); + } else { + return word; + } + }).join(""); +} + +//let cc = 0, ce = 0; +/* +function getChar(c: string): string { + //if (c === "e") { ce++; } + if (c >= 'a' && c <= 'z') { return c; } + if (c.charCodeAt(1)) { + throw new Error(`bad char: "${ c }"`); + } + //cc++; + return ""; + if (c.charCodeAt(0) === 768) { return "`"; } + if (c.charCodeAt(0) === 769) { return "'"; } + if (c.charCodeAt(0) === 771) { return "~"; } + throw new Error(`Unsupported character: ${ c } (${ c.charCodeAt(0) }, ${ c.charCodeAt(1) })`); +} +function mangle(text: string): { word: string, special: string } { + const result: Array = [ ]; + for (let i = 0; i < text.length; i++) { + const c = getChar(text[i]); + result.push(c); + } + + const word = result.join(""); + if (word[1] >= 'a' && word[1] <= 'z') { return { word, special: " " }; } + return { word: word[0] + word.substring(2), special: word[1] }; +} +*/ +/* + Store: [ accent ][ targets ][ rle data; base64-tail ] + ` ae 3, 100 = (63, 37), 15 + ~ n 63, 64 = (63, 1), 27 +*/ + +const Base64 = ")!@#$%^&*(ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"; +export class BitWriter { + readonly width: number; + readonly #data: Array; + + #bitLength: number; + + constructor(width: number) { + this.width = width; + this.#data = [ ]; + this.#bitLength = 0; + } + + write(value: number): void { + const maxValue = ((1 << this.width) - 1); + while (value > maxValue) { + this.#data.push(0); + this.#bitLength += this.width; + value -= maxValue; + } + this.#data.push(value); + this.#bitLength += this.width; + } + + get length(): number { + return 1 + Math.trunc((this.#bitLength + 5) / 6); + } + + get data(): string { + let result = String(this.width); + let bits = 0; + let accum = 0; + + const data = this.#data.slice(); + let bitMod = this.#bitLength % 6; + while (bitMod !== 0 && bitMod < 6) { + data.push(0); + bitMod += this.width; + } + + for (const value of data) { + accum <<= this.width; + accum |= value; + bits += this.width; + + if (bits < 6) { continue; } + + result += Base64[accum >> (bits - 6)]; + bits -= 6; + accum &= ((1 << bits) - 1); + } + + if (result.length !== this.length) { + throw new Error(`Hmm: ${ this.length } ${ result.length } ${ result }`); + } + return result; + } +} + +export interface AccentSet { + accent: number; + follows: string; + positions: Array; + positionsLength: number; + positionData: string; + positionDataLength: number; +}; + +function sorted(text: string): string { + const letters = text.split(""); + letters.sort(); + return letters.join(""); +} + +// if (c.charCodeAt(0) === 768) { return "`"; } +// if (c.charCodeAt(0) === 769) { return "'"; } +// if (c.charCodeAt(0) === 771) { return "~"; } +export function extractAccents(words: Array): { accents: Array, words: Array } { + + // Build a list that maps accents to the letters it can follow + const followsMap: Map = new Map(); + for (const word of words) { + for (let i = 0; i < word.length; i++) { + const c = word[i]; + if (c >= 'a' && c <= 'z') { continue; } + + // Make sure this positions and codepoint make sense + if (c.charCodeAt(1)) { throw new Error(`unsupported codepoint: "${ c }"`); } + if (i === 0) { throw new Error(`unmatched accent: ${ c }`); } + + const ac = c.charCodeAt(0), lastLetter = word[i - 1];; + const follows = (followsMap.get(ac) || ""); + if (follows.indexOf(lastLetter) === -1) { + followsMap.set(ac, sorted(follows + lastLetter)); + } + } + } + + // Build the positions of each follow-set for those accents + const positionsMap: Map> = new Map(); + for (const [ accent, follows ] of followsMap) { + let count = 0; + for (const word of words) { + for (let i = 0; i < word.length; i++) { + const c = word[i], ac = c.charCodeAt(0); + if (follows.indexOf(c) >= 0) { count++; } + if (ac === accent) { + const pos = positionsMap.get(ac) || [ ]; + pos.push(count); + positionsMap.set(ac, pos); + } + } + } + } + + const accents: Array = [ ]; + for (const [ accent, follows ] of followsMap) { + let last = -1; + const positions = (positionsMap.get(accent) || [ ]).map((value, index) => { + const delta = value - last; + last = value; + if (index === 0) { return value; } + return delta; + }); + + // Find the best encoding of the position data + let positionData = ""; + for (let i = 2; i < 7; i++) { + const bitWriter = new BitWriter(i); + for (const p of positions) { bitWriter.write(p); } + if (positionData === "" || bitWriter.length < positionData.length) { + positionData = bitWriter.data; + } + } + const positionsLength = positions.length; + const positionDataLength = positionData.length; + + accents.push({ accent, follows, positions, positionsLength, positionData, positionDataLength }); + } + + words = words.map((word) => { + let result = ""; + for (let i = 0; i < word.length; i++) { + const c = word[i]; + if (c >= 'a' && c <= 'z') { result += c } + } + return result; + }); + + return { accents, words }; +} + +// Encode Official WordList +export function encodeOwl(words: Array): { subs: string, data: string } { + + // Fold the sorted words by indicating delta for the first 2 letters + let data = camelcase(fold(fold(words, ":"), ";")); + + // Replace semicolons with counts (e.g. ";;;" with "3") + data = data.replace(/(;+)/g, (all, semis) => { + let result = ""; + while (semis.length) { + let count = semis.length; + if (count > 10) { count = 10; } + result += String(count - 1); + semis = semis.substring(count); + } + return result; + }); + + // Finds the best option for a shortcut replacement using the + // unused ascii7 characters + function findBest(): string { + const tally: Record = { }; + const l = 2; + for (let i = l; i < data.length; i++) { + const key = data.substring(i - l, i); + tally[key] = (tally[key] || 0) + 1; + } + + const sorted: Array<{ text: string, count: number, save: number }> = Object.keys(tally).map((text) => { + return { text, count: tally[text], save: (tally[text] * (text.length - 1)) } + }); + sorted.sort((a, b) => (b.save - a.save)); + + return sorted[0].text; + } + + // Make substitutions + let subs = ""; + for (let i = 0; i < subsChrs.length; i++) { + const n = subsChrs[i], o = findBest(); + subs += o; + data = data.split(o).join(n); + } + + return { data, subs }; +} + +// Returns either: +// - OWL data for accent-free latin-1: { data, accentds: "" } +// - OWLA data for accented latin-1: { data, accents } +function encodeWords(_words: Array): { data: string, accents: string } { + const { accents, words } = extractAccents(_words); + const { data, subs } = encodeOwl(words); + const accentData = accents.map(({ accent, follows, positionData }) => { + return `${ follows }${ accent }${ positionData }`; + }).join(","); + + return { + data: `0${ subs }${data}`, + accents: accentData + }; +} + +// CLI +const content = fs.readFileSync(process.argv[2]).toString(); +const words = content.split("\n").filter(Boolean); +const { data, accents } = encodeWords(words); + +if (accents) { + const rec = decodeOwlA(data, accents); + console.log("DATA: ", JSON.stringify(data)); + console.log("ACCENTS: ", JSON.stringify(accents)); + console.log("LENGTH: ", data.length); + console.log("CHECKSUM: ", id(content)); + console.log("RATIO: ", Math.trunc(100 * data.length / content.length) + "%"); + if (rec.join("\n") !== words.join("\n")) { throw new Error("no match!"); } +} else { + const rec = decodeOwl(data); + console.log("DATA: ", JSON.stringify(data)); + console.log("LENGTH: ", data.length); + console.log("CHECKSUM: ", id(content)); + console.log("RATIO: ", Math.trunc(100 * data.length / content.length) + "%"); + if (rec.join("\n") !== words.join("\n")) { throw new Error("no match!"); } +} diff --git a/src.ts/wordlists/index.ts b/src.ts/wordlists/index.ts new file mode 100644 index 000000000..776e4f86e --- /dev/null +++ b/src.ts/wordlists/index.ts @@ -0,0 +1,7 @@ + +export { Wordlist } from "./wordlist.js"; +export { langEn, LangEn } from "./lang-en.js"; +export { wordlists } from "./wordlists.js"; + +export { WordlistOwl } from "./wordlist-owl.js"; +export { WordlistOwlA } from "./wordlist-owla.js"; diff --git a/src.ts/wordlists/lang-cz.ts b/src.ts/wordlists/lang-cz.ts new file mode 100644 index 000000000..debf6fedc --- /dev/null +++ b/src.ts/wordlists/lang-cz.ts @@ -0,0 +1,10 @@ +import { WordlistOwl } from "./wordlist-owl.js"; + +const words = "0itatkastcenaovo$taouleraeki&chor*teci%enbalodaeladet'!Chn=0Di#%E%^1Resa2Rese3CeT'#0EjKohol0Pu)%0A&sDul#Ekdo)Ke)Ti#Ul|3}aOgan%0FaltI$@tPi,%TmaTronom0LasL{i#Ol0Tobus4Yl:B#}R'?TaUb_U/!U^U+Ur!Xer2A^v#Ambo,An#AtrEp)Ike)KoLohOnzOskevUn{#Usin#Z^Zy2Bl.Bn|})D _D#D'aF{Jar(Kv?LdokLvaN^NkrRzaTikVolZola3D+tL.T'#0Ukot:PartRev&3DrDu+J/JnLaLerLkemLn?N.Nn(N'#NtrumNzZ(2O&2KolivUv!4It_N(0Dn(Ke)KrPot0Ak~AlIkRkot2Kli$a:L-oRe[T_Tum1E,1B!a}'#Cib_Fic Fla%KlKr{Mokr!PreseRbyS#T-tiv3Kob,zKt|O^P]mSkSp+jV`]Vo/2AhaOuhoUhopis1Es0BroByt-C@t}ut DnesH+dHo^H,JemJn?Kl`KolaKtAzeDolObn(OgerieOzdSn(T Z(2B@}'noD-HaH'#S SnoT(0Oj?Or>2Nam :9O]gOnomie0EktronIpsa0AilIseO%P!ie2Izo^O/aOpejOs2EjEn%K<)Kymo0Ike)0FR&S]Zky3StOhOup(T!Ub.U/o)0AtO)Yz0IsOjivoOut0Bl.Boj}DinyDl!Dno)D|Jn(KejLin#L#LubMo+N [No,%RalR^RizontRkoRliv>RmonRn.RoskopR$voSpo^St.T'(U[UfUp!Us#V<2Ad[An?Av(Az^Bo+kD.D]D(N-Ob#Oma^OtOu^Oz@St#Ub(Yz!2B@(B~D[KotMrS aSto)0Ozd2Bn(D,ntGie&M&Sterik:2Yl#3Ned2O&0Uze0Un a0F-%Fla%KasoOva%Sp-%Tern{Vali^Ve$N)rRmarkRoSanSnoT#VD+Dn!_HlanKotL@L oMn(NomP?S{erV Zd>Zero3NakNdyNo/Sk,Sto)Trn?Zva3En|1Gurt5R):Bar{B_Bin{}&D{Did]HanJakJu)KaoKtusLam aLhotyLibrLn(Me,MkolivM&Ni[lNoeNtB#BlihaBylaC*rH=J@>KosKtejlLapsLe^LizeLoMandoMe)MikMn!aMo,MpasMun aN!N%ptNd?N>NfeseNgresN.NkursN)ktNzervaPan>PieP~Pr'#Rb_R-tSt#T_T+)T*lUk!Up_&Us-Uz]VbojZaZMe+cMivoOcanOkOni#Op OupaOv#T-Uh`]Up?Ut(Vin#Y/+Yp)Y$alYt2Dlan#FrJn(KlaLaj^Li/L#Lom{Ltu,NaPodivuRtRzV`]:B,d<})nDn(IkKom>M_aMpaN'#S?SoStu,Tin#V.3B#CkdyD@Dn?D'#Dv.G@^GieG,%H%Hk(H~KtvarNo/odNtil#P@#Pid]T`]T>TmoTokruhVhartV a%Vobok3B,}ot#DojedDsk(H'.Jav>L-M{#NieN#No+umStop`T.T|5Bi$aDivodGi#GopedKal aK{Mc|P!aPu/RdSosTrU^lUhU#Usk!V>3Tiv(1Cer&CiferMpSkSt,%0I%2RaRi#S.:DamD]Gi$rHagonJ{-J _J< aKakK'?Kr_aL[L.L|Lv?Min#Nd+NkoRn(SakrSkotSopu$T?Tri#Tur aZan>ZivoZl Zur#2Lo[0}anikD a%D'.LasaL*nNtol#TlaTo^TrZe,3G,%H~Hu+K.KrofonL@>Lim{rL(Mi#Nc'&Ni[rNom{Nul(S#StrX|2Ad(HaH'.OkS!Uv 1I/Ohem0BilCn(D_#Dl [HylaKroL-ulaM@t#Nar/aNoklN$rumNt|NzunSazSkytStTiva%T<#Ty#U/aUdr(Zai#Z-Zol2AmKevTvolaZ{Zut(0T _1DrcF]nL!MieN?S{Ta%ZeumZi#nt3Sliv>0Da:B*r!}yt!Da%Dbyt-DhozDobroDpisHlasHn!Hodi+H,d Iv aJedn*Ji$oJm=K]n Kon>Krm LevoMaz!Mluv Nom{rOkoOpakO$roP`!PevnoPln P~Pos+dPr(oRod RubyRy/]S` S-!S+poSt!TolikV@-Vr/VzdR&Ru[RysSahSluhaS)r!UvVazVin VodVyk+Yv!_Z<0AsElEn Hl` Ho)H,&It~0BojByt}odCiz Ebr!Esl!Evzd!EzvaH`%Hod J{JinudKazK*p LivLu#Ml#Oln(P`PisPl=PLivoLu[Mf+tMls-N@#Ni#N&N|N$voNtof+Pri#Rke)RodieR)Ru#Ry[Se#Siv aSt_#T@tTro&V*kZnehtZ*r-3C#DagogJs-K]LotonNal)Ndr-NzeRiskopRoStr(Tar^T?Tro+jVn.Xeso3Ani$aHaJav?K+KnikL.Ln(Lul#Nze)Pe)S!_Sto+Tev&Vn?V'ar2A%n)Ak!Am@Ane)A$i#At Avid]AzE/Em@oEn)EsEtivoEv_Iv!N NoO/aOd.Om[OutUkYn2Bav Byt}odC Ctiv>D!D%n Deps!Dh+dDiv Dkl`Dman DnikDo[Dpo,D,zD$!aDvodDzimEzieHan#Hnut#H'S*d SpoluS)vaSud-SypTahT#nT+skTom-T,vaTupaTvo,U#zUtoUzdroVahaVidlaVlakVozVr/V$!VykVzde/Zd,vZem-Zn!-ZAp<-AseAv^IncipKnoObud O%ntoOdejOfeseOh,Oj-tO]m Omi+Onik!Op`OrokOs[OtonOut-OvazS#v#St@Udk(UtV-VohOvodTruh0Actvo0Ber)}DlKav>Kl.Kr+LtMpaNcP@SaSin#St.T|Ty#3Rami^SkT_::C-}otDia%Dn?DonFtGbyKe)K'.M@oMp*/NdeRa/R aS'&StrTo+$Zan%Zid]3Ag|Ak%CeptDaktMizd!Mo)N #Rdin#San#T_ Z[Z@?0Or0H|1B,n#CeseD`]Dim@tD]Hn!Jm=Ke,K)Kun^KvojeM@oNoRvisS` Sho,SkokSl!St,SuvSyp!T[T.Tk!T~Trv!VerZ&m2O^R~0FonLn?R#Rot-RupTua%1AfandrAliskoAnz@AutEptikIcaL`[L@?LoLuzO[O#nOroRip)RzUp.V(Vr&0Abi#Adid]An.A$Avn(Ed|Ep>EvaEz.IbI&Izn?OnOup-OvoU/UhaUn%Up#Za0A,gdE)&Il$voL*vaOgR`RkRt#Ut-Ysl0AdAhaOb0Bo)}aD'#KolP#TvaUbojUc Ud%UhlasUl`Um,kUp,vaUsedUtokUvis{0Al'&As _IsLavOd-Oj@>OluOnzOvn!P@StUb1An?Ar(aAti#Av[EhnoEz#OdolaO+kOpaOrnoOup!Ra/ResRh~RomRu&Ud&Upn?VolYk0Bj-tBtropy}arD(KnoNd!N=Rik!aR'.0AhAl$voEtrAt[Az-Is+It-Obo^Odid]Or#Rab2Kav#KotN-N'>P!Pk(R'(S_T(:B+t#Bu+H*nJemnoJfunJgaJ Jn(Kti#Mh+MponNc|N>NkerPe)V@.Z!_3}ni#HdyKut.LefonMno)Nd@%Ni$aNU/l Uhl?UsV!2DyH~H(Nd,Ri$aR&jZemsko0ArohOr[Rd(Rz2GrKev:0Oh(OzeR!R*s-RusYt'&0HoTiv(0Iv 3R` 1Edn!I$ M=0Az!_Lidn Lon Otv Roj 0I%I)Ov 0Yv`]0Av IfR*s 1Al Oln Oz'#3D,v ElEn.L.N!:GonL/aL*nNaN^lNil#RanRhanyR|1ElkuHod0Ova0DroGe)%J%Lbl*dL{rhL _LmocLry[Nk'Ran^RzeS_#SkrzeSn?SpoduS)Ter.Ver#3B,%}rDeoh,D.D+LaN?S{Tal aZeZ #0Ezd0L`Us0Aj#AkAs>EvoHk(IvN'#Oup!1Uc|Uk0DaDiv(Doz&kD$voJ@skyJ&JskoLantL[L LnoSk'#Zid]Z'&0Ravo1Ab>A%tAhA)Ba}o+kH!StvaTu+0Ad T*p Tup0Ip4Bav Br!}|D!D,Fot H+d!H~Hod H,d Hub Jasn J{Jm=K]p Kon!L-!Maz!Mez Miz{Mys+tNe/!Nik!Nut P`!Pl! P,v Pu$ Raz R'n!Rv!Sl' SokoS)v Su~Syp!Tas Tes!Tr! Vi~Vol!Vrh_Zdob Zn!0AduBud }op DJ{Ji$ K+p!K*p Lep Mez Mot!Mys+tNe/!Nik!Pl! Poj Ps!Raz S)v Su~Taj Temn Tk~Ujm=Val Ve+tVin Vol!Vrt!Zvon 0Av RusuUd|Yt-1A+#ArmaAtn(IvoOb RojVihYm`]0L@.ManM.Pt!Z`uZdola2At Lt~Lubo#Ot' Ru[0MaMn?0Emn 0Lam!Oum!R!#Umav#0AtoEh#O[OmO$Ozvyk0Ap|ArAt-IjeIz{Ocn Odr!Rzl.Ut|0AkAl(Am@!Ovu0B,z Tav Ub-Ufa+0Lod Omal RavaR( Rud#Rvu1A^An C`]N (NoOv&Y/l Zav(1I/aR! 0B'.Br0Ed~EnkuEs_aOnR!Uk'odYk"; +const checksum = "0x25f44555f4af25b51a711136e1c7d6e50ce9f8917d39d6b1f076b2bb4d2fac1a"; + +export class LangCz extends WordlistOwl { + constructor() { super("cz", words, checksum); } +} + +export const langCz = new LangCz(); diff --git a/src.ts/wordlists/lang-en.ts b/src.ts/wordlists/lang-en.ts new file mode 100644 index 000000000..9fa27bc0d --- /dev/null +++ b/src.ts/wordlists/lang-en.ts @@ -0,0 +1,10 @@ +import { WordlistOwl } from "./wordlist-owl.js"; + +const words = "0erleonalorenseinceregesticitStanvetearctssi#ch2Athck&tneLl0And#Il.yLeOutO=S|S%b/ra@SurdU'0Ce[Cid|CountCu'Hie=IdOu,-Qui*Ro[TT]T%T*[Tu$0AptDD-tD*[Ju,M.UltV<)Vi)0Rob-0FairF%dRaid0A(EEntRee0Ead0MRRp%tS!_rmBumCoholErtI&LLeyLowMo,O}PhaReadySoT Ways0A>urAz(gOngOuntU'd0Aly,Ch%Ci|G G!GryIm$K!Noun)Nu$O` Sw T&naTiqueXietyY1ArtOlogyPe?P!Pro=Ril1ChCt-EaEnaGueMMedM%MyOundR<+Re,Ri=RowTTefa@Ti,Tw%k0KPe@SaultSetSi,SumeThma0H!>OmTa{T&dT.udeTra@0Ct]D.Gu,NtTh%ToTumn0Era+OcadoOid0AkeA*AyEsomeFulKw?d0Is:ByChel%C#D+GL<)Lc#y~MbooN_{Ad!AftAmA}AshAt AwlAzyEamEd.EekEwI{etImeIspIt-OpO[Ou^OwdUci$UelUi'Umb!Un^UshYY,$2BeLtu*PPbo?dRiousRr|Rta(R=Sh]/omTe3C!:DMa+MpN)Ng R(gShUght WnY3AlBa>BrisCadeCemb CideCl(eC%a>C*a'ErF&'F(eFyG*eLayLiv M3AgramAlAm#dAryCeE'lEtFf G.$Gn.yLemmaNn NosaurRe@RtSag*eScov Sea'ShSmi[S%d Splay/<)V tVideV%)Zzy5Ct%Cum|G~Lph(Ma(Na>NkeyN%OrSeUb!Ve_ftAg#AmaA,-AwEamE[IftIllInkIpI=OpUmY2CkMbNeR(g/T^Ty1Arf1Nam-:G G!RlyRnR`Sily/Sy1HoOlogyOnomy0GeItUca>1F%t0G1GhtTh 2BowD E@r-EgSe0B?kBodyBra)Er+Ot]PloyPow Pty0Ab!A@DD![D%'EmyErgyF%)Ga+G(eH<)JoyLi,OughR-hRollSu*T Ti*TryVelope1Isode0U$Uip0AA'OdeOs]R%Upt0CapeSayS&)Ta>0Ern$H-s1Id&)IlOkeOl=1A@Amp!Ce[Ch<+C.eCludeCu'Ecu>Erci'Hau,Hib.I!I,ItOt-PM&'Mu}Pa@Po'Pro=Pul'0ChCludeComeC*a'DexD-a>Do%Du,ryFN Noc|PutQuirySSue0Em1Ory:CketGu?RZz3AlousAns~yWel9BInKeUr}yY5D+I)MpNg!Ni%Nk/:Ng?oo3EnEpT^upY3CkDD}yNdNgdomSsTT^&TeTt&Wi4EeIfeO{Ow:BBelB%Dd DyKeMpNgua+PtopR+T T(UghUndryVaWWnWsu.Y Zy3Ad AfArnA=Ctu*FtGG$G&dIsu*M#NdNg`NsOp?dSs#Tt Vel3ArB tyBr?yC&'FeFtGhtKeMbM.NkOnQuid/Tt!VeZ?d5AdAnB, C$CkG-NelyNgOpTt yUdUn+VeY$5CkyGga+Mb N?N^Xury3R-s:Ch(eDG-G}tIdIlInJ%KeMm$NNa+Nda>NgoNs]Nu$P!Rb!R^Rg(R(eRketRria+SkSs/ T^T i$ThTrixTt XimumZe3AdowAnAsu*AtCh<-D$DiaLodyLtMb M%yNt]NuRcyR+R.RryShSsa+T$Thod3Dd!DnightLk~]M-NdNimumN%Nu>Rac!Rr%S ySs/akeXXedXtu*5Bi!DelDifyMM|N.%NkeyN, N`OnR$ReRn(gSqu.oTh T]T%Unta(U'VeVie5ChFf(LeLtiplySc!SeumShroomS-/Tu$3Self/ yTh:I=MePk(Rrow/yT]Tu*3ArCkEdGati=G!@I` PhewR=/TTw%kUtr$V WsXt3CeGht5B!I'M(eeOd!Rm$R`SeTab!TeTh(gTi)VelW5C!?Mb R'T:K0EyJe@Li+Scu*S =Ta(Vious0CurEAyEa'Ed+U{UgUn+2EmEtIntL?LeLi)NdNyOlPul?Rt]S.]Ssib!/TatoTt yV tyWd W _@i)Ai'Ed-tEf Epa*Es|EttyEv|I)IdeIm?yIntI%.yIs#Iva>IzeOb!mO)[Odu)Of.OgramOje@Omo>OofOp tyOsp O>@OudOvide2Bl-Dd(g~LpL'Mpk(N^PilPpyR^a'R.yRpo'R'ShTZz!3Ramid:99Al.yAntumArt E,]I{ItIzO>:Bb.Cco#CeCkD?DioIlInI'~yMpN^NdomN+PidReTeTh V&WZ%3AdyAlAs#BelBuildC$lCei=CipeC%dCyc!Du)F!@F%mFu'G]G*tGul?Je@LaxLea'LiefLyMa(Memb M(dMo=Nd NewNtOp&PairPeatPla)P%tQui*ScueSemb!Si,Sour)Sp#'SultTi*T*atTurnUn]Ve$ViewW?d2Y`m0BBb#CeChDeD+F!GhtGidNgOtPp!SkTu$V$V 5AdA,BotBu,CketM<)OfOkieOmSeTa>UghUndU>Y$5Bb DeGLeNNwayR$:DDd!D}[FeIlLadLm#L#LtLu>MeMp!NdTisfyToshiU)Usa+VeY1A!AnA*Att E}HemeHoolI&)I[%sOrp]OutRapRe&RiptRub1AAr^As#AtC#dC*tCt]Cur.yEdEkGm|Le@~M(?Ni%N'Nt&)RiesRvi)Ss]Tt!TupV&_dowAftAllowA*EdEllEriffIeldIftI}IpIv O{OeOotOpOrtOuld O=RimpRugUff!Y0Bl(gCkDeE+GhtGnL|Lk~yLv Mil?Mp!N)NgR&/ Tua>XZe1A>Et^IIllInIrtUll0AbAmEepEnd I)IdeIghtImOgAyEakEelEmEpE*oI{IllIngO{Oma^O}OolOryO=Ra>gyReetRikeR#gRugg!Ud|UffUmb!Y!0Bje@Bm.BwayC)[ChDd&Ff G?G+,ItMm NNnyN'tP PplyP*meReRfa)R+Rpri'RroundR=ySpe@/a(1AllowAmpApArmE?EetIftImIngIt^Ord1MbolMptomRup/em:B!Ck!GIlL|LkNkPeR+tSk/eTtooXi3A^Am~NNGradeHoldOnP Set1BOng::Rd3Ar~ow9UUngU`:3BraRo9NeO"; +const checksum = "0x3c8acc1e7b08d8e76f9fda015ef48dc8c710a73cb7e0f77b2c18a9b5a7adde60"; + +export class LangEn extends WordlistOwl { + constructor() { super("en", words, checksum); } +} + +export const langEn = new LangEn(); diff --git a/src.ts/wordlists/lang-es.ts b/src.ts/wordlists/lang-es.ts new file mode 100644 index 000000000..e2fa5ea9b --- /dev/null +++ b/src.ts/wordlists/lang-es.ts @@ -0,0 +1,11 @@ +import { WordlistOwlA } from "./wordlist-owla.js"; + +const words = "0arertoiotadonoaRteirroenaNonaLsolocoiliaralaorrenadaChoN$n0A>Dom,EjaI!#Oga&O'Or#RazoR*Ue=U<0Ab Adem@CeLe%OmoRa!RozUn0DazD$GeLaM,#S,)T^0AlAnceA+EEl]`E`EstruzI.I<2ErU{U'0Af[nArO)Uc Uf_Ul:BaB^|eH@IleJ Lanz/c.LdeMbuN>Nd-oRb(>RnizR+Scu]S#nSu[Tal]T!@T*Tu%UlZ 3BeBid/=S SoSt@3|oEnNgo2An>OqueUsa2ABi`BoCaCi`DaDegaIn//!oLsaMb-{dNi#N}saiRdeRr SqueTeTinVe{Zal2AvoAzoEchaEveIl=In>IsaOcaOmaOnceO)UjaUs>U#2CeoCleE'EyFan{F.HoIt_L#Rbuj(l(+Sc TacaZ.:Bal=BezaBi`B[CaoDav!D,aErFeI{ImanJaJ.LLam Lc$L&Li{dLleLm/^LvoMaMb$Mel=Mi'Mp}c!Nd?Nel-gu+Nic-#N-.ObaOsPazPi%nPo)Pt Puch((b.RcelRe%Rg(i'RneRpe%R+R%SaS>S!oSpaS#rT^ceT_U{lUsaZo3Bol]D!D+Ld/eb_LoAmpuAnc]ApaAr]I>Is)IvoOqueOzaUle%Up 0Cl.EgoE=EnEr#F[G +M->NeN%P_sR>Rue]SneTaU{d2Am^AnA+AseAveI,)ImaInica2B_Cc~|i'Ci`CoDigoDoF_G!He)JinJoL/ch/eg$Lg Lin/l LmoLum`Mba)M!Mi{Mo&Mpr-deNej}g-oc!Nsej}t PaPi(az.Rba%RchoR&nR.(r!S!SmosS%2AneoAt!E Ec!Ei&EmaIaIm,Ip%IsisOmoOnicaOque%U&Uz2Ad+Ar#At+BoBr*| aEl=En#Er{Es%EvaId Lebr/p/#Mb_Mpl*N-e%O%P.Pul( R$Se'Sf[zVaVi'5BleCeL^Ming}N Ra&Rm*RAu%EchaOrO%U*UjoU^2B@CaGa%G.L$Lle#N&Rm(+Rtun(z SaTo2AcaA'AsaAtisAveIe%Il=IpeIsI#OG Gu!aJaMb_Ng}^Nr((mig('St?Yo5E>ElgaEr%ENgl-$Nt Pit!R S#V,?Zg :7Lo5A]:B$C$C[DoD+nG #GrimaGu`I>M!Mi`Mp --ch-gos%NzaPizRgoRvaStimaTaTexT*U_lV Zo3AlCc~|eC#rErG~Gumb_Ja'Ngu-#NaOnOp &S~TalT[VeY,{3B!%dB+C^D!Di EnzoGaG!oMaMi)M.Mp$NceN&Ne-go)N}t!`Qui&SoS%T!aT$T+2AgaAmaAn#AveEg En Ev Or Ov!Uv@2BoC~CoCu[GicaG+MbrizM}jaTe5|aC*G J}-esPaSt+ToZ:Ce%|oD!aD_Du+Est+F@G@GoIzL{dLe%Ll/oMaMboMutN>N&Nej Ng-iquiNj N}Re(f?Rg,Ri&RmolR+nR)sRzoSaSc aSivoT T!@TizTrizXimoY^Z^ca3|aDal]D$Du]J?]J^L,/.M^i-^NsajeN)NuRca&R,gueRi#SS.TaT!To&T+Zc]3E&ElEmb+G/Lag+Lit Ll.M}-!}im}u#OpeR SaS!@S?SmoTadTo5|?aC~DaDe=HoJ LdeL!Li'M,#Mi- c-ed-j-#NoRad(d!Re'R*R+Rs(%lScaStr TivoV!V?Zo5|oD EbleE]Er)Est[G_J!L/e%L%N&Nec(alRoScu=SeoSgoSicaS=:C C~D IpeRanj(izRr SalTalTivoTu[lUseaValVeVi{d3C$Ct G Goc$G+OnRv$ToUt+V V!a3|oDoEb]E#NezNoTi&Vel5Bleza|eMin(i(m()TaTic@Va#Ve]V$5BeCaCleoD?=DoE[EveEzLoM!oTr@:Sis0EC~E[In On!T TicaUes#1Ac~A&rAlBi%CaD,EjaGa'G@Gul=I,)Ig,Il]OQues%Uga0Ad@Cu+Ez'OT[0O'Ro1EjaU=1I&Ige'0En)0O':C#D_El]Gi`GoIsJ oLabr/>Le%Li&Lm/om/p NNalNi>Nt!-ue=PaPelP?]Que)R Rcel(edR*RoRpa&RqueR[foR)S SeoS~SoS%TaT$Tr@UsaU%VoYa<3A#nCa&C!a|oDalD*G IneL L{'Le/ig+LlejoLoLuc--s N.OnOrPi'Que'R(ch(d!Rez(f?Ri>Rl(mizEgun%Em$EnsaE|!oD^Eb=Er%Es#Lg/*Lm.LpoLrNd*N%P #Pet*PoN{PaP!oSaScaSt+T 5BiB^DoE{G*I&In/e%LoMboM^Ptu[TaTi`:Ba&B!B$BleC GazG[&L/&L!oL*Lm.L.Ls/#LudLv Mb-c~Ndi-e Ng_Ni{dN}#PoQueRdin()nSt_TanU`Xof.3Cc~CoC_#C%DGu*IsL=LvaMa`M?l-d-Re'Rg*S#T?:Ba>BiqueB]BuCoC#JoL L>L,#Ll/.Ma'Mb^Ng}quePaPe)P@P.Qu?l(deRe(if(je%RotR+R%TuajeU+ZaZ.3At+|oC]CnicaJa&J!Ji&L/efo'MaM^Mp=NazNd!N!NisNRmi'Rnur(+rSisSo+StigoT!aX#Z3B$Bu+nEmpoEn{Er[EPoR(.TanT!eTu=Za5Al]B?=C Ci'DoG/&M N}#P PeQueRaxR!oRm,%RneoRoRpe&R_RS!Xi>2AbajoAc#rA!Afi>AgoAjeAmoAnceA#AumaAz EbolEguaEin%EnEp EsIbuIgoIpaIs)IunfoOfeoOmpaOn>OpaO)OzoU>Ue'Ufa2B!@BoEr#MbaM^NelNic(bin(ismoR'T^:0Ic 9C!a0B[l0I{dIrIv! = null; + +function hex(word: string) { + return hexlify(toUtf8Bytes(word)); +} + +const KiYoKu = "0xe3818de38284e3818f"; +const KyoKu = "0xe3818de38283e3818f" + +function toString(data: Array): string { + return toUtf8String(new Uint8Array(data)); +} + +function loadWords(): Array { + if (_wordlist !== null) { return _wordlist; } + + const wordlist = []; + + // Transforms for normalizing (sort is a not quite UTF-8) + const transform: { [key: string]: string | boolean } = {}; + + // Delete the diacritic marks + transform[toString([227, 130, 154])] = false; + transform[toString([227, 130, 153])] = false; + + // Some simple transforms that sort out most of the order + transform[toString([227, 130, 133])] = toString([227, 130, 134]); + transform[toString([227, 129, 163])] = toString([227, 129, 164]); + transform[toString([227, 130, 131])] = toString([227, 130, 132]); + transform[toString([227, 130, 135])] = toString([227, 130, 136]); + + + // Normalize words using the transform + function normalize(word: string) { + let result = ""; + for (let i = 0; i < word.length; i++) { + let kana = word[i]; + const target = transform[kana]; + if (target === false) { continue; } + if (target) { kana = target; } + result += kana; + } + return result; + } + + // Sort how the Japanese list is sorted + function sortJapanese(a: string, b: string) { + a = normalize(a); + b = normalize(b); + if (a < b) { return -1; } + if (a > b) { return 1; } + return 0; + } + + // Load all the words + for (let length = 3; length <= 9; length++) { + const d = data[length - 3]; + for (let offset = 0; offset < d.length; offset += length) { + const word = []; + for (let i = 0; i < length; i++) { + const k = mapping.indexOf(d[offset + i]); + word.push(227); + word.push((k & 0x40) ? 130: 129); + word.push((k & 0x3f) + 128); + } + wordlist.push(toString(word)); + } + } + wordlist.sort(sortJapanese); + + // For some reason kyoku and kiyoku are flipped in node (!!). + // The order SHOULD be: + // - kyoku + // - kiyoku + + // This should ignore "if", but that doesn't work here?? + /* c8 ignore start */ + if (hex(wordlist[442]) === KiYoKu && hex(wordlist[443]) === KyoKu) { + const tmp = wordlist[442]; + wordlist[442] = wordlist[443]; + wordlist[443] = tmp; + } + /* c8 ignore stop */ + + // Verify the computed list matches the official list + /* istanbul ignore if */ + const checksum = id(wordlist.join("\n") + "\n"); + /* c8 ignore start */ + if (checksum !== "0xcb36b09e6baa935787fd762ce65e80b0c6a8dabdfbc3a7f86ac0e2c4fd111600") { + throw new Error("BIP39 Wordlist for ja (Japanese) FAILED"); + } + /* c8 ignore stop */ + + _wordlist = wordlist; + + return wordlist; +} + +class LangJa extends Wordlist { + constructor() { super("ja"); } + + getWord(index: number): string { + const words = loadWords(); + if (index < 0 || index >= words.length) { + logger.throwArgumentError(`invalid word index: ${ index }`, "index", index); + } + return words[index]; + } + + getWordIndex(word: string): number { + return loadWords().indexOf(word); + } + + split(mnemonic: string): Array { + //logger.assertNormalize(); + return mnemonic.split(/(?:\u3000| )+/g); + } + + join(words: Array): string { + return words.join("\u3000"); + } +} + +export const langJa = new LangJa(); diff --git a/src.ts/wordlists/lang-ko.ts b/src.ts/wordlists/lang-ko.ts new file mode 100644 index 000000000..bcefa95a6 --- /dev/null +++ b/src.ts/wordlists/lang-ko.ts @@ -0,0 +1,83 @@ +import { id } from "../hash/id.js"; +import { logger, toUtf8String } from "../utils/index.js"; + +import { Wordlist } from "./wordlist.js"; + + +const data = [ + "OYAa", + "ATAZoATBl3ATCTrATCl8ATDloATGg3ATHT8ATJT8ATJl3ATLlvATLn4ATMT8ATMX8ATMboATMgoAToLbAToMTATrHgATvHnAT3AnAT3JbAT3MTAT8DbAT8JTAT8LmAT8MYAT8MbAT#LnAUHT8AUHZvAUJXrAUJX8AULnrAXJnvAXLUoAXLgvAXMn6AXRg3AXrMbAX3JTAX3QbAYLn3AZLgvAZrSUAZvAcAZ8AaAZ8AbAZ8AnAZ8HnAZ8LgAZ8MYAZ8MgAZ8OnAaAboAaDTrAaFTrAaJTrAaJboAaLVoAaMXvAaOl8AaSeoAbAUoAbAg8AbAl4AbGnrAbMT8AbMXrAbMn4AbQb8AbSV8AbvRlAb8AUAb8AnAb8HgAb8JTAb8NTAb8RbAcGboAcLnvAcMT8AcMX8AcSToAcrAaAcrFnAc8AbAc8MgAfGgrAfHboAfJnvAfLV8AfLkoAfMT8AfMnoAfQb8AfScrAfSgrAgAZ8AgFl3AgGX8AgHZvAgHgrAgJXoAgJX8AgJboAgLZoAgLn4AgOX8AgoATAgoAnAgoCUAgoJgAgoLXAgoMYAgoSeAgrDUAgrJTAhrFnAhrLjAhrQgAjAgoAjJnrAkMX8AkOnoAlCTvAlCV8AlClvAlFg4AlFl6AlFn3AloSnAlrAXAlrAfAlrFUAlrFbAlrGgAlrOXAlvKnAlvMTAl3AbAl3MnAnATrAnAcrAnCZ3AnCl8AnDg8AnFboAnFl3AnHX4AnHbrAnHgrAnIl3AnJgvAnLXoAnLX4AnLbrAnLgrAnLhrAnMXoAnMgrAnOn3AnSbrAnSeoAnvLnAn3OnCTGgvCTSlvCTvAUCTvKnCTvNTCT3CZCT3GUCT3MTCT8HnCUCZrCULf8CULnvCU3HnCU3JUCY6NUCbDb8CbFZoCbLnrCboOTCboScCbrFnCbvLnCb8AgCb8HgCb$LnCkLfoClBn3CloDUDTHT8DTLl3DTSU8DTrAaDTrLXDTrLjDTrOYDTrOgDTvFXDTvFnDT3HUDT3LfDUCT9DUDT4DUFVoDUFV8DUFkoDUGgrDUJnrDULl8DUMT8DUMXrDUMX4DUMg8DUOUoDUOgvDUOg8DUSToDUSZ8DbDXoDbDgoDbGT8DbJn3DbLg3DbLn4DbMXrDbMg8DbOToDboJXGTClvGTDT8GTFZrGTLVoGTLlvGTLl3GTMg8GTOTvGTSlrGToCUGTrDgGTrJYGTrScGTtLnGTvAnGTvQgGUCZrGUDTvGUFZoGUHXrGULnvGUMT8GUoMgGXoLnGXrMXGXrMnGXvFnGYLnvGZOnvGZvOnGZ8LaGZ8LmGbAl3GbDYvGbDlrGbHX3GbJl4GbLV8GbLn3GbMn4GboJTGboRfGbvFUGb3GUGb4JnGgDX3GgFl$GgJlrGgLX6GgLZoGgLf8GgOXoGgrAgGgrJXGgrMYGgrScGgvATGgvOYGnAgoGnJgvGnLZoGnLg3GnLnrGnQn8GnSbrGnrMgHTClvHTDToHTFT3HTQT8HToJTHToJgHTrDUHTrMnHTvFYHTvRfHT8MnHT8SUHUAZ8HUBb4HUDTvHUoMYHXFl6HXJX6HXQlrHXrAUHXrMnHXrSbHXvFYHXvKXHX3LjHX3MeHYvQlHZrScHZvDbHbAcrHbFT3HbFl3HbJT8HbLTrHbMT8HbMXrHbMbrHbQb8HbSX3HboDbHboJTHbrFUHbrHgHbrJTHb8JTHb8MnHb8QgHgAlrHgDT3HgGgrHgHgrHgJTrHgJT8HgLX@HgLnrHgMT8HgMX8HgMboHgOnrHgQToHgRg3HgoHgHgrCbHgrFnHgrLVHgvAcHgvAfHnAloHnCTrHnCnvHnGTrHnGZ8HnGnvHnJT8HnLf8HnLkvHnMg8HnRTrITvFUITvFnJTAXrJTCV8JTFT3JTFT8JTFn4JTGgvJTHT8JTJT8JTJXvJTJl3JTJnvJTLX4JTLf8JTLhvJTMT8JTMXrJTMnrJTObrJTQT8JTSlvJT8DUJT8FkJT8MTJT8OXJT8OgJT8QUJT8RfJUHZoJXFT4JXFlrJXGZ8JXGnrJXLV8JXLgvJXMXoJXMX3JXNboJXPlvJXoJTJXoLkJXrAXJXrHUJXrJgJXvJTJXvOnJX4KnJYAl3JYJT8JYLhvJYQToJYrQXJY6NUJbAl3JbCZrJbDloJbGT8JbGgrJbJXvJbJboJbLf8JbLhrJbLl3JbMnvJbRg8JbSZ8JboDbJbrCZJbrSUJb3KnJb8LnJfRn8JgAXrJgCZrJgDTrJgGZrJgGZ8JgHToJgJT8JgJXoJgJgvJgLX4JgLZ3JgLZ8JgLn4JgMgrJgMn4JgOgvJgPX6JgRnvJgSToJgoCZJgoJbJgoMYJgrJXJgrJgJgrLjJg6MTJlCn3JlGgvJlJl8Jl4AnJl8FnJl8HgJnAToJnATrJnAbvJnDUoJnGnrJnJXrJnJXvJnLhvJnLnrJnLnvJnMToJnMT8JnMXvJnMX3JnMg8JnMlrJnMn4JnOX8JnST4JnSX3JnoAgJnoAnJnoJTJnoObJnrAbJnrAkJnrHnJnrJTJnrJYJnrOYJnrScJnvCUJnvFaJnvJgJnvJnJnvOYJnvQUJnvRUJn3FnJn3JTKnFl3KnLT6LTDlvLTMnoLTOn3LTRl3LTSb4LTSlrLToAnLToJgLTrAULTrAcLTrCULTrHgLTrMgLT3JnLULnrLUMX8LUoJgLVATrLVDTrLVLb8LVoJgLV8MgLV8RTLXDg3LXFlrLXrCnLXrLXLX3GTLX4GgLX4OYLZAXrLZAcrLZAgrLZAhrLZDXyLZDlrLZFbrLZFl3LZJX6LZJX8LZLc8LZLnrLZSU8LZoJTLZoJnLZrAgLZrAnLZrJYLZrLULZrMgLZrSkLZvAnLZvGULZvJeLZvOTLZ3FZLZ4JXLZ8STLZ8ScLaAT3LaAl3LaHT8LaJTrLaJT8LaJXrLaJgvLaJl4LaLVoLaMXrLaMXvLaMX8LbClvLbFToLbHlrLbJn4LbLZ3LbLhvLbMXrLbMnoLbvSULcLnrLc8HnLc8MTLdrMnLeAgoLeOgvLeOn3LfAl3LfLnvLfMl3LfOX8Lf8AnLf8JXLf8LXLgJTrLgJXrLgJl8LgMX8LgRZrLhCToLhrAbLhrFULhrJXLhvJYLjHTrLjHX4LjJX8LjLhrLjSX3LjSZ4LkFX4LkGZ8LkGgvLkJTrLkMXoLkSToLkSU8LkSZ8LkoOYLl3FfLl3MgLmAZrLmCbrLmGgrLmHboLmJnoLmJn3LmLfoLmLhrLmSToLnAX6LnAb6LnCZ3LnCb3LnDTvLnDb8LnFl3LnGnrLnHZvLnHgvLnITvLnJT8LnJX8LnJlvLnLf8LnLg6LnLhvLnLnoLnMXrLnMg8LnQlvLnSbrLnrAgLnrAnLnrDbLnrFkLnrJdLnrMULnrOYLnrSTLnvAnLnvDULnvHgLnvOYLnvOnLn3GgLn4DULn4JTLn4JnMTAZoMTAloMTDb8MTFT8MTJnoMTJnrMTLZrMTLhrMTLkvMTMX8MTRTrMToATMTrDnMTrOnMT3JnMT4MnMT8FUMT8FaMT8FlMT8GTMT8GbMT8GnMT8HnMT8JTMT8JbMT8OTMUCl8MUJTrMUJU8MUMX8MURTrMUSToMXAX6MXAb6MXCZoMXFXrMXHXrMXLgvMXOgoMXrAUMXrAnMXrHgMXrJYMXrJnMXrMTMXrMgMXrOYMXrSZMXrSgMXvDUMXvOTMX3JgMX3OTMX4JnMX8DbMX8FnMX8HbMX8HgMX8HnMX8LbMX8MnMX8OnMYAb8MYGboMYHTvMYHX4MYLTrMYLnvMYMToMYOgvMYRg3MYSTrMbAToMbAXrMbAl3MbAn8MbGZ8MbJT8MbJXrMbMXvMbMX8MbMnoMbrMUMb8AfMb8FbMb8FkMcJXoMeLnrMgFl3MgGTvMgGXoMgGgrMgGnrMgHT8MgHZrMgJnoMgLnrMgLnvMgMT8MgQUoMgrHnMgvAnMg8HgMg8JYMg8LfMloJnMl8ATMl8AXMl8JYMnAToMnAT4MnAZ8MnAl3MnAl4MnCl8MnHT8MnHg8MnJnoMnLZoMnLhrMnMXoMnMX3MnMnrMnOgvMnrFbMnrFfMnrFnMnrNTMnvJXNTMl8OTCT3OTFV8OTFn3OTHZvOTJXrOTOl3OT3ATOT3JUOT3LZOT3LeOT3MbOT8ATOT8AbOT8AgOT8MbOUCXvOUMX3OXHXvOXLl3OXrMUOXvDbOX6NUOX8JbOYFZoOYLbrOYLkoOYMg8OYSX3ObHTrObHT4ObJgrObLhrObMX3ObOX8Ob8FnOeAlrOeJT8OeJXrOeJnrOeLToOeMb8OgJXoOgLXoOgMnrOgOXrOgOloOgoAgOgoJbOgoMYOgoSTOg8AbOjLX4OjMnoOjSV8OnLVoOnrAgOn3DUPXQlrPXvFXPbvFTPdAT3PlFn3PnvFbQTLn4QToAgQToMTQULV8QURg8QUoJnQXCXvQbFbrQb8AaQb8AcQb8FbQb8MYQb8ScQeAlrQeLhrQjAn3QlFXoQloJgQloSnRTLnvRTrGURTrJTRUJZrRUoJlRUrQnRZrLmRZrMnRZrSnRZ8ATRZ8JbRZ8ScRbMT8RbST3RfGZrRfMX8RfMgrRfSZrRnAbrRnGT8RnvJgRnvLfRnvMTRn8AaSTClvSTJgrSTOXrSTRg3STRnvSToAcSToAfSToAnSToHnSToLjSToMTSTrAaSTrEUST3BYST8AgST8LmSUAZvSUAgrSUDT4SUDT8SUGgvSUJXoSUJXvSULTrSU8JTSU8LjSV8AnSV8JgSXFToSXLf8SYvAnSZrDUSZrMUSZrMnSZ8HgSZ8JTSZ8JgSZ8MYSZ8QUSaQUoSbCT3SbHToSbQYvSbSl4SboJnSbvFbSb8HbSb8JgSb8OTScGZrScHgrScJTvScMT8ScSToScoHbScrMTScvAnSeAZrSeAcrSeHboSeJUoSeLhrSeMT8SeMXrSe6JgSgHTrSkJnoSkLnvSk8CUSlFl3SlrSnSl8GnSmAboSmGT8SmJU8", + "ATLnDlATrAZoATrJX4ATrMT8ATrMX4ATrRTrATvDl8ATvJUoATvMl8AT3AToAT3MX8AT8CT3AT8DT8AT8HZrAT8HgoAUAgFnAUCTFnAXoMX8AXrAT8AXrGgvAXrJXvAXrOgoAXvLl3AZvAgoAZvFbrAZvJXoAZvJl8AZvJn3AZvMX8AZvSbrAZ8FZoAZ8LZ8AZ8MU8AZ8OTvAZ8SV8AZ8SX3AbAgFZAboJnoAbvGboAb8ATrAb8AZoAb8AgrAb8Al4Ab8Db8Ab8JnoAb8LX4Ab8LZrAb8LhrAb8MT8Ab8OUoAb8Qb8Ab8ST8AcrAUoAcrAc8AcrCZ3AcrFT3AcrFZrAcrJl4AcrJn3AcrMX3AcrOTvAc8AZ8Ac8MT8AfAcJXAgoFn4AgoGgvAgoGnrAgoLc8AgoMXoAgrLnrAkrSZ8AlFXCTAloHboAlrHbrAlrLhrAlrLkoAl3CZrAl3LUoAl3LZrAnrAl4AnrMT8An3HT4BT3IToBX4MnvBb!Ln$CTGXMnCToLZ4CTrHT8CT3JTrCT3RZrCT#GTvCU6GgvCU8Db8CU8GZrCU8HT8CboLl3CbrGgrCbrMU8Cb8DT3Cb8GnrCb8LX4Cb8MT8Cb8ObrCgrGgvCgrKX4Cl8FZoDTrAbvDTrDboDTrGT6DTrJgrDTrMX3DTrRZrDTrRg8DTvAVvDTvFZoDT3DT8DT3Ln3DT4HZrDT4MT8DT8AlrDT8MT8DUAkGbDUDbJnDYLnQlDbDUOYDbMTAnDbMXSnDboAT3DboFn4DboLnvDj6JTrGTCgFTGTGgFnGTJTMnGTLnPlGToJT8GTrCT3GTrLVoGTrLnvGTrMX3GTrMboGTvKl3GZClFnGZrDT3GZ8DTrGZ8FZ8GZ8MXvGZ8On8GZ8ST3GbCnQXGbMbFnGboFboGboJg3GboMXoGb3JTvGb3JboGb3Mn6Gb3Qb8GgDXLjGgMnAUGgrDloGgrHX4GgrSToGgvAXrGgvAZvGgvFbrGgvLl3GgvMnvGnDnLXGnrATrGnrMboGnuLl3HTATMnHTAgCnHTCTCTHTrGTvHTrHTvHTrJX8HTrLl8HTrMT8HTrMgoHTrOTrHTuOn3HTvAZrHTvDTvHTvGboHTvJU8HTvLl3HTvMXrHTvQb4HT4GT6HT4JT8HT4Jb#HT8Al3HT8GZrHT8GgrHT8HX4HT8Jb8HT8JnoHT8LTrHT8LgvHT8SToHT8SV8HUoJUoHUoJX8HUoLnrHXrLZoHXvAl3HX3LnrHX4FkvHX4LhrHX4MXoHX4OnoHZrAZ8HZrDb8HZrGZ8HZrJnrHZvGZ8HZvLnvHZ8JnvHZ8LhrHbCXJlHbMTAnHboJl4HbpLl3HbrJX8HbrLnrHbrMnvHbvRYrHgoSTrHgrFV8HgrGZ8HgrJXoHgrRnvHgvBb!HgvGTrHgvHX4HgvHn!HgvLTrHgvSU8HnDnLbHnFbJbHnvDn8Hn6GgvHn!BTvJTCTLnJTQgFnJTrAnvJTrLX4JTrOUoJTvFn3JTvLnrJTvNToJT3AgoJT3Jn4JT3LhvJT3ObrJT8AcrJT8Al3JT8JT8JT8JnoJT8LX4JT8LnrJT8MX3JT8Rg3JT8Sc8JUoBTvJU8AToJU8GZ8JU8GgvJU8JTrJU8JXrJU8JnrJU8LnvJU8ScvJXHnJlJXrGgvJXrJU8JXrLhrJXrMT8JXrMXrJXrQUoJXvCTvJXvGZ8JXvGgrJXvQT8JX8Ab8JX8DT8JX8GZ8JX8HZvJX8LnrJX8MT8JX8MXoJX8MnvJX8ST3JYGnCTJbAkGbJbCTAnJbLTAcJboDT3JboLb6JbrAnvJbrCn3JbrDl8JbrGboJbrIZoJbrJnvJbrMnvJbrQb4Jb8RZrJeAbAnJgJnFbJgScAnJgrATrJgvHZ8JgvMn4JlJlFbJlLiQXJlLjOnJlRbOlJlvNXoJlvRl3Jl4AcrJl8AUoJl8MnrJnFnMlJnHgGbJnoDT8JnoFV8JnoGgvJnoIT8JnoQToJnoRg3JnrCZ3JnrGgrJnrHTvJnrLf8JnrOX8JnvAT3JnvFZoJnvGT8JnvJl4JnvMT8JnvMX8JnvOXrJnvPX6JnvSX3JnvSZrJn3MT8Jn3MX8Jn3RTrLTATKnLTJnLTLTMXKnLTRTQlLToGb8LTrAZ8LTrCZ8LTrDb8LTrHT8LT3PX6LT4FZoLT$CTvLT$GgrLUvHX3LVoATrLVoAgoLVoJboLVoMX3LVoRg3LV8CZ3LV8FZoLV8GTvLXrDXoLXrFbrLXvAgvLXvFlrLXvLl3LXvRn6LX4Mb8LX8GT8LYCXMnLYrMnrLZoSTvLZrAZvLZrAloLZrFToLZrJXvLZrJboLZrJl4LZrLnrLZrMT8LZrOgvLZrRnvLZrST4LZvMX8LZvSlvLZ8AgoLZ8CT3LZ8JT8LZ8LV8LZ8LZoLZ8Lg8LZ8SV8LZ8SbrLZ$HT8LZ$Mn4La6CTvLbFbMnLbRYFTLbSnFZLboJT8LbrAT9LbrGb3LbrQb8LcrJX8LcrMXrLerHTvLerJbrLerNboLgrDb8LgrGZ8LgrHTrLgrMXrLgrSU8LgvJTrLgvLl3Lg6Ll3LhrLnrLhrMT8LhvAl4LiLnQXLkoAgrLkoJT8LkoJn4LlrSU8Ll3FZoLl3HTrLl3JX8Ll3JnoLl3LToLmLeFbLnDUFbLnLVAnLnrATrLnrAZoLnrAb8LnrAlrLnrGgvLnrJU8LnrLZrLnrLhrLnrMb8LnrOXrLnrSZ8LnvAb4LnvDTrLnvDl8LnvHTrLnvHbrLnvJT8LnvJU8LnvJbrLnvLhvLnvMX8LnvMb8LnvNnoLnvSU8Ln3Al3Ln4FZoLn4GT6Ln4JgvLn4LhrLn4MT8Ln4SToMToCZrMToJX8MToLX4MToLf8MToRg3MTrEloMTvGb6MT3BTrMT3Lb6MT8AcrMT8AgrMT8GZrMT8JnoMT8LnrMT8MX3MUOUAnMXAbFnMXoAloMXoJX8MXoLf8MXoLl8MXrAb8MXrDTvMXrGT8MXrGgrMXrHTrMXrLf8MXrMU8MXrOXvMXrQb8MXvGT8MXvHTrMXvLVoMX3AX3MX3Jn3MX3LhrMX3MX3MX4AlrMX4OboMX8GTvMX8GZrMX8GgrMX8JT8MX8JX8MX8LhrMX8MT8MYDUFbMYMgDbMbGnFfMbvLX4MbvLl3Mb8Mb8Mb8ST4MgGXCnMg8ATrMg8AgoMg8CZrMg8DTrMg8DboMg8HTrMg8JgrMg8LT8MloJXoMl8AhrMl8JT8MnLgAUMnoJXrMnoLX4MnoLhrMnoMT8MnrAl4MnrDb8MnrOTvMnrOgvMnrQb8MnrSU8MnvGgrMnvHZ8Mn3MToMn4DTrMn4LTrMn4Mg8NnBXAnOTFTFnOToAToOTrGgvOTrJX8OT3JXoOT6MTrOT8GgrOT8HTpOT8MToOUoHT8OUoJT8OUoLn3OXrAgoOXrDg8OXrMT8OXvSToOX6CTvOX8CZrOX8OgrOb6HgvOb8AToOb8MT8OcvLZ8OgvAlrOgvHTvOgvJTrOgvJnrOgvLZrOgvLn4OgvMT8OgvRTrOg8AZoOg8DbvOnrOXoOnvJn4OnvLhvOnvRTrOn3GgoOn3JnvOn6JbvOn8OTrPTGYFTPbBnFnPbGnDnPgDYQTPlrAnvPlrETvPlrLnvPlrMXvPlvFX4QTMTAnQTrJU8QYCnJlQYJlQlQbGTQbQb8JnrQb8LZoQb8LnvQb8MT8Qb8Ml8Qb8ST4QloAl4QloHZvQloJX8QloMn8QnJZOlRTrAZvRTrDTrRTvJn4RTvLhvRT4Jb8RZrAZrRZ8AkrRZ8JU8RZ8LV8RZ8LnvRbJlQXRg3GboRg3MnvRg8AZ8Rg8JboRg8Jl4RnLTCbRnvFl3RnvQb8SToAl4SToCZrSToFZoSToHXrSToJU8SToJgvSToJl4SToLhrSToMX3STrAlvSTrCT9STrCgrSTrGgrSTrHXrSTrHboSTrJnoSTrNboSTvLnrST4AZoST8Ab8ST8JT8SUoJn3SU6HZ#SU6JTvSU8Db8SU8HboSU8LgrSV8JT8SZrAcrSZrAl3SZrJT8SZrJnvSZrMT8SZvLUoSZ4FZoSZ8JnoSZ8RZrScoLnrScoMT8ScoMX8ScrAT4ScrAZ8ScrLZ8ScrLkvScvDb8ScvLf8ScvNToSgrFZrShvKnrSloHUoSloLnrSlrMXoSl8HgrSmrJUoSn3BX6", + "ATFlOn3ATLgrDYAT4MTAnAT8LTMnAYJnRTrAbGgJnrAbLV8LnAbvNTAnAeFbLg3AgOYMXoAlQbFboAnDboAfAnJgoJTBToDgAnBUJbAl3BboDUAnCTDlvLnCTFTrSnCYoQTLnDTwAbAnDUDTrSnDUHgHgrDX8LXFnDbJXAcrETvLTLnGTFTQbrGTMnGToGT3DUFbGUJlPX3GbQg8LnGboJbFnGb3GgAYGgAg8ScGgMbAXrGgvAbAnGnJTLnvGnvATFgHTDT6ATHTrDlJnHYLnMn8HZrSbJTHZ8LTFnHbFTJUoHgSeMT8HgrLjAnHgvAbAnHlFUrDlHnDgvAnHnHTFT3HnQTGnrJTAaMXvJTGbCn3JTOgrAnJXvAXMnJbMg8SnJbMnRg3Jb8LTMnJnAl3OnJnGYrQlJnJlQY3LTDlCn3LTJjLg3LTLgvFXLTMg3GTLV8HUOgLXFZLg3LXNXrMnLX8QXFnLX9AlMYLYLXPXrLZAbJU8LZDUJU8LZMXrSnLZ$AgFnLaPXrDULbFYrMnLbMn8LXLboJgJgLeFbLg3LgLZrSnLgOYAgoLhrRnJlLkCTrSnLkOnLhrLnFX%AYLnFZoJXLnHTvJbLnLloAbMTATLf8MTHgJn3MTMXrAXMT3MTFnMUITvFnMXFX%AYMXMXvFbMXrFTDbMYAcMX3MbLf8SnMb8JbFnMgMXrMTMgvAXFnMgvGgCmMnAloSnMnFnJTrOXvMXSnOX8HTMnObJT8ScObLZFl3ObMXCZoPTLgrQXPUFnoQXPU3RXJlPX3RkQXPbrJXQlPlrJbFnQUAhrDbQXGnCXvQYLnHlvQbLfLnvRTOgvJbRXJYrQlRYLnrQlRbLnrQlRlFT8JlRlFnrQXSTClCn3STHTrAnSTLZQlrSTMnGTrSToHgGbSTrGTDnSTvGXCnST3HgFbSU3HXAXSbAnJn3SbFT8LnScLfLnv", + "AT3JgJX8AT8FZoSnAT8JgFV8AT8LhrDbAZ8JT8DbAb8GgLhrAb8SkLnvAe8MT8SnAlMYJXLVAl3GYDTvAl3LfLnvBUDTvLl3CTOn3HTrCT3DUGgrCU8MT8AbCbFTrJUoCgrDb8MTDTLV8JX8DTLnLXQlDT8LZrSnDUQb8FZ8DUST4JnvDb8ScOUoDj6GbJl4GTLfCYMlGToAXvFnGboAXvLnGgAcrJn3GgvFnSToGnLf8JnvGn#HTDToHTLnFXJlHTvATFToHTvHTDToHTvMTAgoHT3STClvHT4AlFl6HT8HTDToHUoDgJTrHUoScMX3HbRZrMXoHboJg8LTHgDb8JTrHgMToLf8HgvLnLnoHnHn3HT4Hn6MgvAnJTJU8ScvJT3AaQT8JT8HTrAnJXrRg8AnJbAloMXoJbrATFToJbvMnoSnJgDb6GgvJgDb8MXoJgSX3JU8JguATFToJlPYLnQlJlQkDnLbJlQlFYJlJl8Lf8OTJnCTFnLbJnLTHXMnJnLXGXCnJnoFfRg3JnrMYRg3Jn3HgFl3KT8Dg8LnLTRlFnPTLTvPbLbvLVoSbrCZLXMY6HT3LXNU7DlrLXNXDTATLX8DX8LnLZDb8JU8LZMnoLhrLZSToJU8LZrLaLnrLZvJn3SnLZ8LhrSnLaJnoMT8LbFlrHTvLbrFTLnrLbvATLlvLb6OTFn3LcLnJZOlLeAT6Mn4LeJT3ObrLg6LXFlrLhrJg8LnLhvDlPX4LhvLfLnvLj6JTFT3LnFbrMXoLnQluCTvLnrQXCY6LnvLfLnvLnvMgLnvLnvSeLf8MTMbrJn3MT3JgST3MT8AnATrMT8LULnrMUMToCZrMUScvLf8MXoDT8SnMX6ATFToMX8AXMT8MX8FkMT8MX8HTrDUMX8ScoSnMYJT6CTvMgAcrMXoMg8SToAfMlvAXLg3MnFl3AnvOT3AnFl3OUoATHT8OU3RnLXrOXrOXrSnObPbvFn6Og8HgrSnOg8OX8DbPTvAgoJgPU3RYLnrPXrDnJZrPb8CTGgvPlrLTDlvPlvFUJnoQUvFXrQlQeMnoAl3QlrQlrSnRTFTrJUoSTDlLiLXSTFg6HT3STJgoMn4STrFTJTrSTrLZFl3ST4FnMXoSUrDlHUoScvHTvSnSfLkvMXo", + "AUoAcrMXoAZ8HboAg8AbOg6ATFgAg8AloMXoAl3AT8JTrAl8MX8MXoCT3SToJU8Cl8Db8MXoDT8HgrATrDboOT8MXoGTOTrATMnGT8LhrAZ8GnvFnGnQXHToGgvAcrHTvAXvLl3HbrAZoMXoHgBlFXLg3HgMnFXrSnHgrSb8JUoHn6HT8LgvITvATrJUoJUoLZrRnvJU8HT8Jb8JXvFX8QT8JXvLToJTrJYrQnGnQXJgrJnoATrJnoJU8ScvJnvMnvMXoLTCTLgrJXLTJlRTvQlLbRnJlQYvLbrMb8LnvLbvFn3RnoLdCVSTGZrLeSTvGXCnLg3MnoLn3MToLlrETvMT8SToAl3MbrDU6GTvMb8LX4LhrPlrLXGXCnSToLf8Rg3STrDb8LTrSTvLTHXMnSb3RYLnMnSgOg6ATFg", + "HUDlGnrQXrJTrHgLnrAcJYMb8DULc8LTvFgGnCk3Mg8JbAnLX4QYvFYHnMXrRUoJnGnvFnRlvFTJlQnoSTrBXHXrLYSUJgLfoMT8Se8DTrHbDb", + "AbDl8SToJU8An3RbAb8ST8DUSTrGnrAgoLbFU6Db8LTrMg8AaHT8Jb8ObDl8SToJU8Pb3RlvFYoJl" +] + +const codes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*" + +function getHangul(code: number): string { + if (code >= 40) { + code = code + 168 - 40; + } else if (code >= 19) { + code = code + 97 - 19; + } + + return toUtf8String(new Uint8Array([ 225, (code >> 6) + 132, (code & 0x3f) + 128])); +} + +let _wordlist: null | Array = null; + +function loadWords(): Array { + if (_wordlist != null) { return _wordlist; } + + const wordlist: Array = [ ]; + + data.forEach((data, length) => { + length += 4; + for (let i = 0; i < data.length; i += length) { + let word = ""; + for (let j = 0; j < length; j++) { + word += getHangul(codes.indexOf(data[i + j])); + } + wordlist.push(word) + } + }); + + wordlist.sort(); + + // Verify the computed list matches the official list + /* istanbul ignore if */ + const checksum = id(wordlist.join("\n") + "\n"); + /* c8 ignore start */ + if (checksum !== "0xf9eddeace9c5d3da9c93cf7d3cd38f6a13ed3affb933259ae865714e8a3ae71a") { + throw new Error("BIP39 Wordlist for ko (Korean) FAILED"); + } + /* c8 ignore stop */ + + _wordlist = wordlist; + + return wordlist; +} + + +class LangKo extends Wordlist { + constructor() { + super("ko"); + } + + getWord(index: number): string { + const words = loadWords(); + if (index < 0 || index >= words.length) { + logger.throwArgumentError(`invalid word index: ${ index }`, "index", index); + } + return words[index]; + } + + getWordIndex(word: string): number { + return loadWords().indexOf(word); + } +} + +export const langKo = new LangKo(); diff --git a/src.ts/wordlists/lang-pt.ts b/src.ts/wordlists/lang-pt.ts new file mode 100644 index 000000000..07807c96a --- /dev/null +++ b/src.ts/wordlists/lang-pt.ts @@ -0,0 +1,10 @@ +import { WordlistOwl } from "./wordlist-owl.js"; + +const words = "0arad!ototealirertainrasoent hoandoaR#riareha!aroele'oronul0Aca%AixoAl A%rDuz'El]Er$IsmoO$ Rum S-&T(i&TigoVo[=0F&.Il#P' S?S* So&/Sun$Tr&0Ac#Adu+Al/A[f E End(Er_EuIng'Ir?IvoOl{oRac Revi=RizU&Um0Di$rM-.R>o+TismoT|@Tu 0Ali An%Ar@Ent&Es,I?Is Ul,1Ila1Ar E=Ei%Ulejo:B BosaC&]uCh `C@GagemI+c>~/Se#S)n%Ta)Te=rTidaTomTuc Unil]3B(IjoIr^IsebolLd!eLezaLgaLisc Ndi$Ng&aNz(RimbauRl*d>_Sou_XigaZ(_3CoCu=En&Foc&Furc G|naLhe%Mest[Mo$rOlog@OmboOsf(aPol Rr-$Scoi$Sne$SpoSsex$TolaZ _2Ind#OcoOque 2A$BagemC#CejoChec]Ico.L^LetimL]LoMb{oNdeNecoNi)Rb~h>d>e&R+c]V*oXe?2AncoAsaAvezaEuIgaIl/Inc OaOchu+Onze O$Uxo2C]DismoF LeRacoScaS$Z*a:Bimb Rn{oRpe%R['>)zRv&/SacoScaSeb[S%loS~oT a)Tiv UleUs?U%l V&oV(na3BolaDil]G}]Lebr L~ Nou+N,N%ioRc Rr#R%'oRvejaTimV^2Aco)Al{aAm#Ap^ArmeAticeAveEfeEg^E'oEqueIco%If[In`oOc&/Ov(UmboU.Uva0CatrizCl}eD!eD['aEn%Gcui$Rurg@T 2A[zaE_Ic OneUbe2A=Ag'Ba@B($rBr C^El/Ent_E,Gum`oIb'IfaIo%L L{aLh(Lid'Lme@L}oLunaM<=Mb* M-.MitivaMov(MplexoMumNc]N=rNec.Nfu,Ng` Nhec(Njug Nsum'Nt+$Nvi%Op( P{oPi?PoQue%lRagemRdi&Rne)R}h>p|&R[ioR%joRuj>voSs-oS%laT}e%U_UveVilZ*]2A%+AvoEcheE=rEmeErEspoI^Im*&Io~oIseItic Os)UaUz{o2B+m SafioSbo.Sc<,S-/Sfi#Sgas%Sigu&SlizeSmam SovaSpesaS)queSvi T&h T-$rT} Tri$UsaV(Vi=Vot#Z-a3Ag+maAle$Da)Fu,Gi.Lat#Lu-%M*u'Nast@Nh{oOceseRe$Sc[)Sf ceSp oSque%Ssip S)n%T?UrnoV(,Vi,rV~g Z(5Br?L|i=M?M*#NativoNz`>m-%Rs&SagemUr#U$r2EnagemIbleOg @2El EndeE$PloQues><%Vi=,:1Lod'O Olog@0Ific It&Uc#1Ei$Etiv 3E.1Ab| Eg(Ei$rEncoEv?Im* Ogi 0B goBol#Br~/Buti=EndaErg'Is,rPat@P-/P*#Polg P[goPurr Ul?0CaixeC-#Ch-%C}t_Deus Doss Faix Fei%FimGaj#G-/Glob Gom#G+x Gu@Jo La.Qu<$Raiz Rol#Rug SaioSe^S*oSop#T<$Te#Tid!eT|.Tr^T~/V(g Vi#Volv(XameX($Xof[Xu$1Id(me0Uip 0E$Gui=Ra)VaVil]0Bopeu0Acu Ap| AsivoEntu&Id-%Olu'1Ag(oAl Am* A$Aus$Ces,Ci.Clam Ecu.EmploIb'Ig-%On( Pof>p>tu+T@T|V|i)X*aZ-da3Ch#Ijo^I+n%L*oM**oNdaNoR>i#RrugemRv(S%j T&Ud&3ApoB_seC Ch{oGur#L{aL/LmeLtr RmezaSg^Ssu+TaV`aX?Xo2AcidezAm*goAn`aEch^O+Utu Uxo2C&C*/Foc GoGue%IceLg#Lhe$Rj Rmig>noR%ScoSsa2Aga)AldaAngoAscoA%rnoE'aEn%E.IezaI,Itu+On]Ustr U%'a2G'L+faSodu$S$TaTil/Ve)Z`a3L#Le@LoM^M(Mi=N(o,NgivaNi&NomaN_Ologi>?Rm* S,S$r3Nas)Nc*o2Aci&IcoseOb&Orio,2ElaIabaLfeLpe Rdu+Rje)R_S$,T{aV(n 2AcejoAdu&Afi%Al]AmpoAn^Atui$Ave$AxaEgoElh EveIloIs&/I.@Os,O%scoUd#Unhi=U)2AcheA+niAx*imEr[ I Inc/Is#LaLo,Ru:Bi.Rm}@S%V(3C.eRd Res@Si.3A$B(n D+.EnaNoPismoPnosePo%ca5JeLofo%MemNes$Nr#Rm}&Sped 5M|#:Te2E@O,2N|#RejaUdimR_SmimToV&iZida3Jum9An*]Elh^G?I>n&Rr Vem5BaDeuDocaIzLg?L/R#Ris)RoS)::B edaB|&C[C)n%Dril/G )GoaJeMb(M-.M* MpejoNchePid P,R{>gu+S<]St_T(&Ti=VfimRgemR*/Rmi)Ro$RquiseR[coR%loRujoSco%Sm|+SsagemStig Tag&T(noT*&Tu.Xil 3D&]DidaDusaGaf}eIgaLc/Sc~ SeuSic&:Ci}&D?JaMo_R*>r#Sc(TivaTu[zaV&]Veg Vio3Bl*aB~o,GativaGoci Gri$Rvo,TaUr&VascaVo{o3N N/TidezV` 5B[zaI%IvaMe M*&Rdes%R% T Tici TurnoV`oVil/Vo5Bl#DezM(&Pci&Tr'Vem:0Cec#Edec(JetivoRig#Scu_S%t+T(Tur 0Id-%Io,Orr(Ulis)Up#2Eg<%EnsivaEr-daIc*aUsc#0Iva4Ar@Eo,H Iv{a0B_Ele%Is,It'0D~#E_,Tem1Ci}&Er?On-%OrtunoOs$1ArBi.DemD*&Fci&Rd&RedeRtidaSmoSs#S%lTam T-%T* T_noUl^Us 3C~i D& Dest[D@t+D+G^I$r&IxeLeLicplexoRsi<>%nceRucaSc#SquisaS,aTisc 3AdaC#Ed!eGm-$Last+Lh#Lo.M-)Nc`NguimN]No%N.On{oPocaQue%ResRue)Sc S$laTg-$Rje)Tur Ud!eXof}eZ}&3C C~ DaD-$Di#Do,Du$rGm-$G[=Gun=IvaLe$LvagemM<&M-%N?N/rNsu&Nt#P #Rei>*g>+RvoTemb_T|3GiloLhue)Lic}eMetr@Mpat@M~ N&Nc(oNg~ NopseN$ni>-eRiTu#5B(fis)Rp[s>[&Rt'Sp'oS%n$:B`aBle%Bu^C/G `aLh(LoLvezMdioRef>j>+xaTuagemUr*oXativoXis)3Atr&C(Ci=Cl#Dio,IaIm Lef}eLh#Mp(oN-%N,rN.Rm&RnoRr-oSeSou+St#ToXtu+Xugo3A+G`aJoloMbr MidezNgi=N%'oRagemT~ 5Al]C]L( LiceM^Mil/N`Ntu+Pe%R>ci=RneioRqueRr!>$S.UcaUp{aX*a2Ab&/Acej Adu$rAfeg Aje$AmaAnc ApoAs{oAt?Av E*oEm(Epid EvoIagemIboIcicloId-%Ilog@Ind!eIploItur Iunf&Oc Ombe)OvaUnfoUque2B~ CquesaT` T|i&:7V 3Bigo0HaId!eIf|me3Olog@SoTigaUbu0A=InaUfru':C*aDi G o,I=,LaL-%Lid!eLo[sN)gemQu{oRe)Rr(Sc~ Sil]S,u+Z Zio3A=D Ge.Ic~ L{oLhiceLu=Nce=rNdav&N( Nt[Rb&Rd!eRe?Rg}h>m`/RnizRs R%n%SpaSti=T|i&3Adu$AgemAj Atu+Br?D{aDr @ElaGaG-%Gi G| L ejoNcoNhe)NilOle)R!>tudeSi.S$Tr&V{oZ*/5A=rArG&L<%LeibolL)gemLumo,Nt!e5L$Vuz`a::D[zRope3QueRe.Rife3Ng ::Ng#Rp 3BuL?9Mb Olog@5Mbi="; +const checksum = "0x2219000926df7b50d8aa0a3d495826b988287df4657fbd100e6fe596c8f737ac"; + +export class LangPt extends WordlistOwl { + constructor() { super("pt", words, checksum); } +} + +export const langPt = new LangPt(); diff --git a/src.ts/wordlists/lang-zh.ts b/src.ts/wordlists/lang-zh.ts new file mode 100644 index 000000000..67b94a82b --- /dev/null +++ b/src.ts/wordlists/lang-zh.ts @@ -0,0 +1,83 @@ +import { id } from "../hash/index.js"; +import { toUtf8String, logger } from "../utils/index.js"; + +import { Wordlist } from "./wordlist.js"; + + +const data = "}aE#4A=Yv&co#4N#6G=cJ&SM#66|/Z#4t&kn~46#4K~4q%b9=IR#7l,mB#7W_X2*dl}Uo~7s}Uf&Iw#9c&cw~6O&H6&wx&IG%v5=IQ~8a&Pv#47$PR&50%Ko&QM&3l#5f,D9#4L|/H&tQ;v0~6n]nN> = { + zh_cn: null, + zh_tw: null +} + +const Checks: Record = { + zh_cn: "0x17bcc4d8547e5a7135e365d1ab443aaae95e76d8230c2782c67305d4f21497a1", + zh_tw: "0x51e720e90c7b87bec1d70eb6e74a21a449bd3ec9c020b01d3a40ed991b60ce5d" +} + +const codes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const style = "~!@#$%^&*_-=[]{}|;:,.()<>?" + +function loadWords(locale: string): Array { + if (_wordlist[locale] != null) { return _wordlist[locale] as Array; } + + const wordlist = []; + + let deltaOffset = 0; + for (let i = 0; i < 2048; i++) { + const s = style.indexOf(data[i * 3]); + const bytes = [ + 228 + (s >> 2), + 128 + codes.indexOf(data[i * 3 + 1]), + 128 + codes.indexOf(data[i * 3 + 2]), + ]; + + if (locale === "zh_tw") { + const common = s % 4; + for (let i = common; i < 3; i++) { + bytes[i] = codes.indexOf(deltaData[deltaOffset++]) + ((i == 0) ? 228: 128); + } + } + + wordlist.push(toUtf8String(new Uint8Array(bytes))); + } + + // Verify the computed list matches the official list + const checksum = id(wordlist.join("\n") + "\n"); + /* c8 ignore start */ + if (checksum !== Checks[locale]) { + throw new Error(`BIP39 Wordlist for ${ locale } (Chinese) FAILED`); + } + /* c8 ignore stop */ + + _wordlist[locale] = wordlist; + + return wordlist; +} + +class LangZh extends Wordlist { + constructor(country: string) { super("zh_" + country); } + + getWord(index: number): string { + const words = loadWords(this.locale); + if (index < 0 || index >= words.length) { + logger.throwArgumentError(`invalid word index: ${ index }`, "index", index); + } + return words[index]; + } + + getWordIndex(word: string): number { + return loadWords(this.locale).indexOf(word); + } + + split(mnemonic: string): Array { + mnemonic = mnemonic.replace(/(?:\u3000| )+/g, ""); + return mnemonic.split(""); + } +} + +export const langZhCn = new LangZh("cn"); +export const langZhTw = new LangZh("tw"); diff --git a/src.ts/wordlists/wordlist-owl.ts b/src.ts/wordlists/wordlist-owl.ts new file mode 100644 index 000000000..5b8d7c0bd --- /dev/null +++ b/src.ts/wordlists/wordlist-owl.ts @@ -0,0 +1,57 @@ + +// Use the encode-latin.js script to create the necessary +// data files to be consumed by this class + +import { id } from "../hash/id.js"; +import { logger } from "../utils/logger.js"; + +import { decodeOwl } from "./decode-owl.js"; +import { Wordlist } from "./wordlist.js"; + +export class WordlistOwl extends Wordlist { + #data: string; + #checksum: string; + + constructor(locale: string, data: string, checksum: string) { + super(locale); + this.#data = data; + this.#checksum = checksum; + this.#words = null; + } + + get _data(): string { return this.#data; } + + _decodeWords(): Array { + return decodeOwl(this.#data); + } + + #words: null | Array; + #loadWords(): Array { + if (this.#words == null) { + const words = this._decodeWords(); + + // Verify the computed list matches the official list + const checksum = id(words.join("\n") + "\n"); + /* c8 ignore start */ + if (checksum !== this.#checksum) { + throw new Error(`BIP39 Wordlist for ${ this.locale } FAILED`); + } + /* c8 ignore stop */ + + this.#words = words; + } + return this.#words; + } + + getWord(index: number): string { + const words = this.#loadWords(); + if (index < 0 || index >= words.length) { + logger.throwArgumentError(`invalid word index: ${ index }`, "index", index); + } + return words[index]; + } + + getWordIndex(word: string): number { + return this.#loadWords().indexOf(word); + } +} diff --git a/src.ts/wordlists/wordlist-owla.ts b/src.ts/wordlists/wordlist-owla.ts new file mode 100644 index 000000000..750905bc4 --- /dev/null +++ b/src.ts/wordlists/wordlist-owla.ts @@ -0,0 +1,18 @@ + +import { WordlistOwl } from "./wordlist-owl.js"; +import { decodeOwlA } from "./decode-owla.js"; + +export class WordlistOwlA extends WordlistOwl { + #accent: string; + + constructor(locale: string, data: string, accent: string, checksum: string) { + super(locale, data, checksum); + this.#accent = accent; + } + + get _accent(): string { return this.#accent; } + + _decodeWords(): Array { + return decodeOwlA(this._data, this._accent); + } +} diff --git a/src.ts/wordlists/wordlist.ts b/src.ts/wordlists/wordlist.ts new file mode 100644 index 000000000..d6e6c0178 --- /dev/null +++ b/src.ts/wordlists/wordlist.ts @@ -0,0 +1,22 @@ +import { defineProperties } from "../utils/index.js"; + +export abstract class Wordlist { + locale!: string; + + constructor(locale: string) { + defineProperties(this, { locale }); + } + + // Subclasses may override this + split(mnemonic: string): Array { + return mnemonic.toLowerCase().split(/ +/g) + } + + // Subclasses may override this + join(words: Array): string { + return words.join(" "); + } + + abstract getWord(index: number): string; + abstract getWordIndex(word: string): number; +} diff --git a/src.ts/wordlists/wordlists-browser.ts b/src.ts/wordlists/wordlists-browser.ts new file mode 100644 index 000000000..fe2bd4e90 --- /dev/null +++ b/src.ts/wordlists/wordlists-browser.ts @@ -0,0 +1,10 @@ + +// wordlists/wordlists-browser.js + +import { langEn as en } from "./lang-en.js"; + +import type { Wordlist } from "./wordlist.js"; + +export const wordlists: Record = Object.freeze({ + en +}); diff --git a/src.ts/wordlists/wordlists-extra.ts b/src.ts/wordlists/wordlists-extra.ts new file mode 100644 index 000000000..21226ea08 --- /dev/null +++ b/src.ts/wordlists/wordlists-extra.ts @@ -0,0 +1,15 @@ + +import { langCz as cz } from "./lang-cz.js"; +import { langEs as es } from "./lang-es.js"; +import { langFr as fr } from "./lang-fr.js"; +import { langJa as ja } from "./lang-ja.js"; +import { langKo as ko } from "./lang-ko.js"; +import { langIt as it } from "./lang-it.js"; +import { langPt as pt } from "./lang-pt.js"; +import { langZhCn as zh_cn, langZhTw as zh_tw } from "./lang-zh.js"; + +import type { Wordlist } from "./wordlist.js"; + +export const wordlists: Record = Object.freeze({ + cz, es, fr, ja, ko, it, pt, zh_cn, zh_tw +}); diff --git a/src.ts/wordlists/wordlists.ts b/src.ts/wordlists/wordlists.ts new file mode 100644 index 000000000..459f93721 --- /dev/null +++ b/src.ts/wordlists/wordlists.ts @@ -0,0 +1,15 @@ +import { langCz as cz } from "./lang-cz.js"; +import { langEn as en } from "./lang-en.js"; +import { langEs as es } from "./lang-es.js"; +import { langFr as fr } from "./lang-fr.js"; +import { langJa as ja } from "./lang-ja.js"; +import { langKo as ko } from "./lang-ko.js"; +import { langIt as it } from "./lang-it.js"; +import { langPt as pt } from "./lang-pt.js"; +import { langZhCn as zh_cn, langZhTw as zh_tw } from "./lang-zh.js"; + +import type { Wordlist } from "./wordlist.js"; + +export const wordlists: Record = Object.freeze({ + cz, en, es, fr, ja, ko, it, pt, zh_cn, zh_tw +}); diff --git a/testcases/README.md b/testcases/README.md new file mode 100644 index 000000000..cd049d253 --- /dev/null +++ b/testcases/README.md @@ -0,0 +1,4 @@ +Testcases +========= + +Please see the [testcase generation repo](https://github.com/ethers-io/testcase-generation-scripts). diff --git a/testcases/abi.json.gz b/testcases/abi.json.gz new file mode 100644 index 000000000..ece572ae6 Binary files /dev/null and b/testcases/abi.json.gz differ diff --git a/testcases/accounts.json.gz b/testcases/accounts.json.gz new file mode 100644 index 000000000..867fc98a3 Binary files /dev/null and b/testcases/accounts.json.gz differ diff --git a/testcases/create.json.gz b/testcases/create.json.gz new file mode 100644 index 000000000..d6385b44f Binary files /dev/null and b/testcases/create.json.gz differ diff --git a/testcases/create2.json.gz b/testcases/create2.json.gz new file mode 100644 index 000000000..47bdeba45 Binary files /dev/null and b/testcases/create2.json.gz differ diff --git a/testcases/hashes.json.gz b/testcases/hashes.json.gz new file mode 100644 index 000000000..67ab38651 Binary files /dev/null and b/testcases/hashes.json.gz differ diff --git a/testcases/hmac.json.gz b/testcases/hmac.json.gz new file mode 100644 index 000000000..2c129d24b Binary files /dev/null and b/testcases/hmac.json.gz differ diff --git a/testcases/mnemonics.json.gz b/testcases/mnemonics.json.gz new file mode 100644 index 000000000..a6251912e Binary files /dev/null and b/testcases/mnemonics.json.gz differ diff --git a/testcases/namehash.json.gz b/testcases/namehash.json.gz new file mode 100644 index 000000000..4f1b2e9aa Binary files /dev/null and b/testcases/namehash.json.gz differ diff --git a/testcases/pbkdf.json.gz b/testcases/pbkdf.json.gz new file mode 100644 index 000000000..b82cb31e7 Binary files /dev/null and b/testcases/pbkdf.json.gz differ diff --git a/testcases/rlp.json.gz b/testcases/rlp.json.gz new file mode 100644 index 000000000..b39749ce4 Binary files /dev/null and b/testcases/rlp.json.gz differ diff --git a/testcases/transaction.json.gz b/testcases/transaction.json.gz new file mode 100644 index 000000000..3039ffaa8 Binary files /dev/null and b/testcases/transaction.json.gz differ diff --git a/testcases/transactions.json.gz b/testcases/transactions.json.gz new file mode 100644 index 000000000..7f6d75b7b Binary files /dev/null and b/testcases/transactions.json.gz differ diff --git a/testcases/typed-data.json.gz b/testcases/typed-data.json.gz new file mode 100644 index 000000000..afe593ccb Binary files /dev/null and b/testcases/typed-data.json.gz differ diff --git a/testcases/wallets.json.gz b/testcases/wallets.json.gz new file mode 100644 index 000000000..7c41a56da Binary files /dev/null and b/testcases/wallets.json.gz differ diff --git a/testcases/wordlists.json.gz b/testcases/wordlists.json.gz new file mode 100644 index 000000000..db4250f50 Binary files /dev/null and b/testcases/wordlists.json.gz differ diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 000000000..b50ed2490 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "declaration": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "importHelpers": true, + "lib": [ + "es2020", + "es5" + ], + "moduleResolution": "node16", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "preserveSymlinks": true, + "preserveWatchOutput": true, + "pretty": false, + "rootDir": "./src.ts", + "strict": true, + "sourceMap": true, + "target": "es2022" + }, + "exclude": [ ], + "include": [ + "./src.ts/**/*.ts" + ], +} diff --git a/tsconfig.commonjs.json b/tsconfig.commonjs.json new file mode 100644 index 000000000..b73f1dd6a --- /dev/null +++ b/tsconfig.commonjs.json @@ -0,0 +1,10 @@ +{ + "exclude": [ + "src.ts/_admin/**" + ], + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./lib.commonjs" + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 000000000..aa45c3b52 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "es2020", + "outDir": "./lib.esm" + } +} diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 000000000..e36ecd691 --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "exclude": [ + "src.ts/_admin/**" + ], + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "declarationDir": "./types", + "emitDeclarationOnly": true + } +}