├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── ci.yml │ └── docs.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── client ├── Build.js ├── contracts │ ├── call.sol │ ├── getBalance.sol │ ├── getStorageAt.sol │ ├── getTransactionCount.sol │ └── newFilter.sol ├── src │ ├── contracts │ │ └── .gitignore │ ├── glue.ts │ ├── index.html │ ├── index.ts │ ├── mocha-browser.d.ts │ ├── reporter.ts │ ├── shim-process.js │ ├── solidity.d.ts │ ├── tests.ts │ ├── tests │ │ └── eth │ │ │ ├── accounts.ts │ │ │ ├── blockNumber.ts │ │ │ ├── call.ts │ │ │ ├── chainId.ts │ │ │ ├── createAccessList.ts │ │ │ ├── estimateGas.ts │ │ │ ├── feeHistory.ts │ │ │ ├── gasPrice.ts │ │ │ ├── getBalance.ts │ │ │ ├── getBlockByHash.ts │ │ │ ├── getBlockByNumber.ts │ │ │ ├── getBlockTransactionCountByHash.ts │ │ │ ├── getBlockTransactionCountByNumber.ts │ │ │ ├── getCode.ts │ │ │ ├── getFilterChanges.ts │ │ │ ├── getFilterLogs.ts │ │ │ ├── getLogs.ts │ │ │ ├── getProof.ts │ │ │ ├── getStorageAt.ts │ │ │ ├── getTransactionByBlockHashAndIndex.ts │ │ │ ├── getTransactionByBlockNumberAndIndex.ts │ │ │ ├── getTransactionByHash.ts │ │ │ ├── getTransactionCount.ts │ │ │ ├── getTransactionReceipt.ts │ │ │ ├── maxPriorityFeePerGas.ts │ │ │ ├── newBlockFilter.ts │ │ │ ├── newFilter.ts │ │ │ ├── newPendingTransactionFilter.ts │ │ │ ├── sendRawTransaction.ts │ │ │ ├── sendTransaction.ts │ │ │ ├── sign.ts │ │ │ ├── signTransaction.ts │ │ │ └── uninstallFilter.ts │ ├── util.ts │ └── worker_chain.ts └── tsconfig.json ├── docs ├── LICENSE.md ├── architecture.md ├── development.md ├── guide-glue.md ├── guide-manual.md ├── guide-testing.md ├── img │ ├── diagram.svg │ ├── glue-001.png │ ├── glue-002.png │ ├── glue-003.png │ ├── glue-004.png │ ├── glue-005.png │ ├── glue-006.png │ ├── glue-007.png │ ├── logo.svg │ └── metamask.png └── index.md ├── mkdocs.yml ├── package-lock.json ├── package.json ├── server ├── Build.js ├── src │ ├── client.ts │ ├── connections.ts │ └── index.ts └── tsconfig.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js,mjs,cjs,ts,json,html,yaml,yml}] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 6 | "prettier", 7 | ], 8 | rules: { 9 | "@typescript-eslint/no-unused-vars": [ 10 | "warn", 11 | { argsIgnorePattern: "^_" }, 12 | ], 13 | }, 14 | parser: "@typescript-eslint/parser", 15 | plugins: ["@typescript-eslint"], 16 | root: true, 17 | parserOptions: { 18 | project: true, 19 | }, 20 | 21 | overrides: [ 22 | { 23 | files: ["**/Build.js"], 24 | rules: { 25 | "@typescript-eslint/no-unsafe-argument": ["off"], 26 | "@typescript-eslint/no-unsafe-assignment": ["off"], 27 | "@typescript-eslint/no-unsafe-call": ["off"], 28 | "@typescript-eslint/no-unsafe-member-access": ["off"], 29 | }, 30 | }, 31 | { 32 | files: ["**/*.d.ts"], 33 | rules: { 34 | "@typescript-eslint/no-explicit-any": ["off"], 35 | }, 36 | }, 37 | ], 38 | }; 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "Continuous Integration" 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "master" 8 | pull_request: 9 | 10 | jobs: 11 | tests: 12 | name: "Tests" 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 18 19 | - run: npm ci 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | permissions: 8 | contents: write 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Configure Git Credentials 15 | run: | 16 | git config user.name github-actions[bot] 17 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.x 21 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 22 | - uses: actions/cache@v4 23 | with: 24 | key: mkdocs-material-${{ env.cache_id }} 25 | path: .cache 26 | restore-keys: | 27 | mkdocs-material- 28 | - run: pip install mkdocs-material mkdocs-nav-weight 29 | - run: mkdocs gh-deploy --force 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | node_modules 3 | /client/src/contracts 4 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["@trivago/prettier-plugin-sort-imports"], 3 | importOrderSeparation: true, 4 | importOrderSortSpecifiers: true, 5 | }; 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023-2024 Binary Cake Ltd. & Contributors 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the “Software”), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wallet Test Framework 2 | 3 | - [blog](https://wtf.allwallet.dev/) 4 | - [documentation](https://wallet-test-framework.github.io/framework/) 5 | 6 | ## Friends of Wallet Test Framework 7 | 8 | We would like to extend our heartfelt thanks to the sponsors making this project possible: 9 | 10 | Brave Logo 14 | Ethereum Logo 18 | 19 | ## Usage 20 | 21 | Optionally, you can [configure npm to install without superuser permissions][unglobal]. 22 | 23 | ```bash 24 | # Install the server 25 | npm install -g @wallet-test-framework/framework 26 | 27 | # Run the server 28 | wtfd 29 | ``` 30 | 31 | [unglobal]: https://github.com/sindresorhus/guides/blob/3f4ad3e30efd384f42384b61b38e82626a4c3b7a/npm-global-without-sudo.md 32 | 33 | ## Development 34 | 35 | ### Building 36 | 37 | To install the dependencies: 38 | 39 | ```bash 40 | npm install --include=dev 41 | ``` 42 | 43 | To compile the TypeScript into JavaScript and create the relevant bundles: 44 | 45 | ```bash 46 | npm run build 47 | ``` 48 | 49 | ### Linting 50 | 51 | Before creating a pull request, please make sure to run: 52 | 53 | ```bash 54 | npm test 55 | ``` 56 | 57 | ### Running 58 | 59 | After building, you can run the web server with: 60 | 61 | ```bash 62 | node dist/server/index.js 63 | ``` 64 | -------------------------------------------------------------------------------- /client/Build.js: -------------------------------------------------------------------------------- 1 | import { typecheckPlugin } from "@jgoz/esbuild-plugin-typecheck"; 2 | import * as esbuild from "esbuild"; 3 | import { readFile, readdir, writeFile } from "node:fs/promises"; 4 | import * as path from "node:path"; 5 | import process from "node:process"; 6 | import { URL, fileURLToPath, pathToFileURL } from "node:url"; 7 | import solc from "solc"; 8 | 9 | const options = { 10 | plugins: [typecheckPlugin()], 11 | 12 | absWorkingDir: fileURLToPath(new URL(".", import.meta.url)), 13 | 14 | entryPoints: ["src/index.ts", "src/worker_chain.ts", "src/index.html"], 15 | 16 | inject: ["src/shim-process.js"], 17 | 18 | loader: { ".html": "copy" }, 19 | 20 | bundle: true, 21 | outbase: "src", 22 | outdir: "../dist/client/", 23 | target: "es2020", 24 | format: "esm", 25 | platform: "browser", 26 | minify: false, 27 | sourcemap: true, 28 | }; 29 | 30 | async function buildSolidity() { 31 | const contracts = fileURLToPath(new URL("./contracts", import.meta.url)); 32 | 33 | const ls = await readdir(contracts, { withFileTypes: true }); 34 | 35 | const sources = {}; 36 | 37 | for (const source of ls) { 38 | if (!source.isFile()) { 39 | continue; 40 | } 41 | const sourcePath = path.join(contracts, source.name); 42 | 43 | sources[source.name] = { 44 | content: await readFile(sourcePath, "utf8"), 45 | }; 46 | } 47 | 48 | const output = JSON.parse( 49 | solc.compile( 50 | JSON.stringify({ 51 | language: "Solidity", 52 | sources, 53 | settings: { 54 | outputSelection: { 55 | "*": { 56 | "*": ["abi", "evm.bytecode.object"], 57 | }, 58 | }, 59 | }, 60 | }), 61 | ), 62 | ); 63 | 64 | for (const file of Object.keys(output.contracts)) { 65 | const outfile = fileURLToPath( 66 | new URL(`./src/contracts/${file}.ts`, import.meta.url), 67 | ); 68 | 69 | let content = ["/* THIS FILE IS AUTOGENERATED */"]; 70 | 71 | for (const contract of Object.keys(output.contracts[file])) { 72 | const bytecode = 73 | output.contracts[file][contract].evm.bytecode.object; 74 | if (typeof bytecode !== "string") { 75 | throw "not a string"; 76 | } 77 | 78 | const prefix = contract.toUpperCase(); 79 | content.push( 80 | `export const ${prefix}_ABI = ${JSON.stringify( 81 | output.contracts[file][contract].abi, 82 | )} as const;`, 83 | ); 84 | content.push( 85 | `export const ${prefix}_BYTECODE = ${JSON.stringify( 86 | "0x" + bytecode, 87 | )};`, 88 | ); 89 | } 90 | 91 | await writeFile(outfile, content); 92 | } 93 | } 94 | 95 | if (import.meta.url === pathToFileURL(process.argv[1]).href) { 96 | await buildSolidity().then(async () => await esbuild.build(options)); 97 | } 98 | 99 | export { options as default }; 100 | -------------------------------------------------------------------------------- /client/contracts/call.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.19; 4 | 5 | contract Call { 6 | 7 | function add1(uint256 arg0) external pure returns (uint256) { 8 | return arg0 + 1; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/contracts/getBalance.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.19; 4 | 5 | contract Receive { 6 | function give() external payable{} 7 | } 8 | -------------------------------------------------------------------------------- /client/contracts/getStorageAt.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.19; 4 | 5 | contract Store { 6 | uint256 var0; 7 | 8 | function store(uint256 arg0) external { 9 | var0 = arg0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/contracts/getTransactionCount.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.19; 4 | contract Dummy { 5 | } 6 | contract Deploy { 7 | function deploy() external { 8 | new Dummy(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /client/contracts/newFilter.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.19; 4 | 5 | contract Emit { 6 | event Log(uint256 indexed arg0); 7 | event DifferentLog(uint256 indexed arg0); 8 | 9 | function logSomething(uint256 arg0) external { 10 | emit Log(arg0); 11 | } 12 | 13 | function logSomethingElse(uint256 arg0) external { 14 | emit DifferentLog(arg0); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/contracts/.gitignore: -------------------------------------------------------------------------------- 1 | /** 2 | !/.gitignore 3 | -------------------------------------------------------------------------------- /client/src/glue.ts: -------------------------------------------------------------------------------- 1 | import { AnyChain } from "./index"; 2 | import { delay } from "./util"; 3 | import { 4 | ActivateChain, 5 | AddEthereumChain, 6 | AddEthereumChainEvent, 7 | EventMap, 8 | Glue, 9 | Report, 10 | RequestAccounts, 11 | RequestAccountsEvent, 12 | SendTransaction, 13 | SendTransactionEvent, 14 | SignMessage, 15 | SignMessageEvent, 16 | SignTransaction, 17 | SignTransactionEvent, 18 | SwitchEthereumChain, 19 | SwitchEthereumChainEvent, 20 | } from "@wallet-test-framework/glue"; 21 | import assert from "assert"; 22 | import { Client as WebSocketClient } from "rpc-websockets"; 23 | 24 | type Events = { [k in keyof EventMap]: null }; 25 | 26 | const EVENTS: (keyof Events)[] = (() => { 27 | const helper: Events = { 28 | requestaccounts: null, 29 | addethereumchain: null, 30 | switchethereumchain: null, 31 | signmessage: null, 32 | sendtransaction: null, 33 | signtransaction: null, 34 | } as const; 35 | 36 | const events: (keyof Events)[] = []; 37 | 38 | let key: keyof Events; 39 | for (key in helper) { 40 | events.push(key); 41 | } 42 | 43 | return events; 44 | })(); 45 | 46 | type TemplateContext = { [key: string]: string | HTMLElement }; 47 | 48 | abstract class Template extends HTMLElement { 49 | public static define( 50 | templateName: string, 51 | ): (new (_: TemplateContext) => Template) & typeof Template { 52 | const clazz = class extends Template { 53 | constructor(values: TemplateContext) { 54 | super(values); 55 | 56 | const template = document.getElementById(templateName); 57 | if (!template) { 58 | throw `missing #${templateName} element`; 59 | } 60 | 61 | if (!("content" in template)) { 62 | throw `element #${templateName} not a template`; 63 | } 64 | 65 | if (!(template.content instanceof DocumentFragment)) { 66 | throw `element #${templateName} not a template`; 67 | } 68 | 69 | const shadowRoot = this.attachShadow({ mode: "open" }); 70 | shadowRoot.appendChild(template.content.cloneNode(true)); 71 | } 72 | }; 73 | customElements.define(templateName, clazz); 74 | return clazz; 75 | } 76 | 77 | constructor(values: TemplateContext) { 78 | super(); 79 | 80 | for (const [key, value] of Object.entries(values)) { 81 | if (value instanceof HTMLElement) { 82 | value.slot = key; 83 | this.appendChild(value); 84 | } else { 85 | const span = document.createElement("span"); 86 | span.slot = key; 87 | span.innerText = value; 88 | this.appendChild(span); 89 | } 90 | } 91 | } 92 | } 93 | 94 | const ActivateChainTemplate = Template.define("wtf-activate-chain"); 95 | const InstructTemplate = Template.define("wtf-instruct"); 96 | const RequestAccountsTemplate = Template.define("wtf-request-accounts"); 97 | const SignMessageTemplate = Template.define("wtf-sign-message"); 98 | const SignTransactionTemplate = Template.define("wtf-sign-transaction"); 99 | const SendTransactionTemplate = Template.define("wtf-send-transaction"); 100 | const AddEthereumChainTemplate = Template.define("wtf-add-ethereum-chain"); 101 | const SwitchEthereumChainTemplate = Template.define( 102 | "wtf-switch-ethereum-chain", 103 | ); 104 | 105 | export class ManualGlue extends Glue { 106 | private readonly rootElement: HTMLElement; 107 | private readonly eventsElement: HTMLElement; 108 | private readonly instructionsElement: HTMLElement; 109 | private readonly reportLinkElement: HTMLAnchorElement; 110 | private readonly reportElement: HTMLDialogElement; 111 | 112 | private readonly wallet: AnyChain; 113 | 114 | constructor(element: HTMLElement, wallet: AnyChain) { 115 | super(); 116 | 117 | this.wallet = wallet; 118 | this.rootElement = element; 119 | 120 | const events = element.getElementsByClassName("events")[0]; 121 | const instructions = element.getElementsByClassName("instructions")[0]; 122 | const reportLink = element.getElementsByClassName("report-link")[0]; 123 | const reportDialog = element.getElementsByClassName("report")[0]; 124 | 125 | if (!(events instanceof HTMLElement)) { 126 | throw "missing .events element"; 127 | } 128 | 129 | if (!(instructions instanceof HTMLElement)) { 130 | throw "missing .instructions element"; 131 | } 132 | 133 | if (!(reportLink instanceof HTMLAnchorElement)) { 134 | throw "missing/incorrect .report-link element"; 135 | } 136 | 137 | if (!(reportDialog instanceof HTMLDialogElement)) { 138 | throw "missing/incorrect .report element"; 139 | } 140 | 141 | this.eventsElement = events; 142 | this.instructionsElement = instructions; 143 | this.reportElement = reportDialog; 144 | this.reportLinkElement = reportLink; 145 | 146 | element.classList.add("glue-active"); 147 | 148 | this.attachEvents(); 149 | } 150 | 151 | private static splitArray(input?: string): undefined | string[] { 152 | if (!input) { 153 | return; 154 | } 155 | 156 | input = input.trim(); 157 | 158 | if (!input.length) { 159 | return; 160 | } 161 | 162 | return input.split(/[\s]+/); 163 | } 164 | 165 | private emitSwitchEthereumChain(data: Map) { 166 | const domain = data.get("domain"); 167 | const chainId = data.get("chain-id"); 168 | 169 | if (!domain || !chainId) { 170 | throw "incomplete switch-ethereum-chain"; 171 | } 172 | 173 | this.emit( 174 | "switchethereumchain", 175 | new SwitchEthereumChainEvent(domain, { 176 | chainId, 177 | }), 178 | ); 179 | } 180 | 181 | private emitAddEthereumChain(data: Map) { 182 | const domain = data.get("domain"); 183 | const chainId = data.get("chain-id"); 184 | let chainName = data.get("chain-name"); 185 | const blockExplorerUrls = ManualGlue.splitArray( 186 | data.get("block-explorer-urls"), 187 | ); 188 | const iconUrls = ManualGlue.splitArray(data.get("icon-urls")); 189 | const rpcUrls = ManualGlue.splitArray(data.get("rpc-urls")); 190 | 191 | // TODO: nativeCurrency 192 | 193 | if (!domain || !chainId) { 194 | throw "incomplete add-ethereum-chain"; 195 | } 196 | 197 | if (chainName?.trim() === "") { 198 | chainName = undefined; 199 | } 200 | 201 | this.emit( 202 | "addethereumchain", 203 | new AddEthereumChainEvent(domain, { 204 | chainId, 205 | chainName, 206 | blockExplorerUrls, 207 | iconUrls, 208 | rpcUrls, 209 | }), 210 | ); 211 | } 212 | 213 | private emitRequestAccounts(data: Map) { 214 | const accountsText = data.get("accounts"); 215 | if (typeof accountsText !== "string") { 216 | throw "form missing accounts"; 217 | } 218 | 219 | const domain = data.get("domain"); 220 | if (typeof domain !== "string") { 221 | throw "form missing domain"; 222 | } 223 | 224 | const accounts = accountsText.split(/[^a-fA-Fx0-9]/); 225 | this.emit( 226 | "requestaccounts", 227 | new RequestAccountsEvent(domain, { accounts }), 228 | ); 229 | } 230 | 231 | private emitSignMessage(data: Map) { 232 | const message = data.get("message"); 233 | if (typeof message !== "string") { 234 | throw "form missing message"; 235 | } 236 | 237 | const domain = data.get("domain"); 238 | if (typeof domain !== "string") { 239 | throw "form missing domain"; 240 | } 241 | 242 | this.emit("signmessage", new SignMessageEvent(domain, { message })); 243 | } 244 | 245 | private emitSendTransaction(data: Map) { 246 | const from = data.get("from"); 247 | if (typeof from !== "string") { 248 | throw "form missing from"; 249 | } 250 | 251 | const to = data.get("to"); 252 | if (typeof to !== "string") { 253 | throw "form missing to"; 254 | } 255 | 256 | const data_ = data.get("data"); 257 | if (typeof data_ !== "string") { 258 | throw "form missing data"; 259 | } 260 | 261 | const value = data.get("value"); 262 | if (typeof value !== "string") { 263 | throw "form missing value"; 264 | } 265 | 266 | const domain = data.get("domain"); 267 | if (typeof domain !== "string") { 268 | throw "form missing domain"; 269 | } 270 | 271 | this.emit( 272 | "sendtransaction", 273 | new SendTransactionEvent(domain, { from, to, value, data: data_ }), 274 | ); 275 | } 276 | 277 | private emitSignTransaction(data: Map) { 278 | const from = data.get("from"); 279 | if (typeof from !== "string") { 280 | throw "form missing from"; 281 | } 282 | 283 | const to = data.get("to"); 284 | if (typeof to !== "string") { 285 | throw "form missing to"; 286 | } 287 | 288 | const data_ = data.get("data"); 289 | if (typeof data_ !== "string") { 290 | throw "form missing data"; 291 | } 292 | 293 | const value = data.get("value"); 294 | if (typeof value !== "string") { 295 | throw "form missing value"; 296 | } 297 | 298 | const domain = data.get("domain"); 299 | if (typeof domain !== "string") { 300 | throw "form missing domain"; 301 | } 302 | 303 | this.emit( 304 | "signtransaction", 305 | new SignTransactionEvent(domain, { from, to, value, data: data_ }), 306 | ); 307 | } 308 | 309 | private attachEvents(): void { 310 | const dialogs = this.eventsElement.querySelectorAll("dialog"); 311 | 312 | type Handlers = { 313 | [key: string]: (_: Map) => void; 314 | }; 315 | 316 | const handlers: Handlers = { 317 | "request-accounts": (d) => this.emitRequestAccounts(d), 318 | "sign-message": (d) => this.emitSignMessage(d), 319 | "send-transaction": (d) => this.emitSendTransaction(d), 320 | "sign-transaction": (d) => this.emitSignTransaction(d), 321 | "add-ethereum-chain": (d) => this.emitAddEthereumChain(d), 322 | "switch-ethereum-chain": (d) => this.emitSwitchEthereumChain(d), 323 | }; 324 | 325 | for (const dialog of dialogs) { 326 | if (!(dialog instanceof HTMLDialogElement)) { 327 | console.warn("element isn't a dialog", dialog); 328 | continue; 329 | } 330 | 331 | if (!(dialog.parentNode instanceof HTMLElement)) { 332 | console.warn("dialog parent isn't an HTMLElement", dialog); 333 | continue; 334 | } 335 | 336 | const button = dialog.parentNode?.querySelector("button"); 337 | if (!(button instanceof HTMLElement)) { 338 | console.warn("dialog has no button", dialog); 339 | continue; 340 | } 341 | 342 | const form = dialog.querySelector("form"); 343 | if (!(form instanceof HTMLFormElement)) { 344 | console.warn("dialog has no form", dialog); 345 | continue; 346 | } 347 | 348 | const handlerId = dialog.dataset.event; 349 | if (!handlerId || !(handlerId in handlers)) { 350 | console.warn("dialog has no matching handler", dialog); 351 | continue; 352 | } 353 | 354 | const handler = handlers[handlerId]; 355 | delete handlers[handlerId]; 356 | 357 | button.addEventListener("click", () => { 358 | form.reset(); 359 | dialog.showModal(); 360 | }); 361 | 362 | form.addEventListener("submit", (e) => { 363 | if (e.submitter && "value" in e.submitter) { 364 | if (e.submitter.value === "cancel") { 365 | return; 366 | } 367 | } 368 | 369 | const rawData = new FormData(form); 370 | const data = new Map(); 371 | for (const [key, value] of rawData) { 372 | if (typeof value === "string") { 373 | data.set(key, value); 374 | } else { 375 | throw `form field ${key} has non-string type`; 376 | } 377 | } 378 | handler(data); 379 | }); 380 | } 381 | 382 | for (const unused of Object.keys(handlers)) { 383 | console.warn("unused handler", unused); 384 | } 385 | } 386 | 387 | override async switchEthereumChain( 388 | action: SwitchEthereumChain, 389 | ): Promise { 390 | if (action.action !== "approve") { 391 | throw "not implemented"; 392 | } 393 | 394 | await this.instruct( 395 | new SwitchEthereumChainTemplate({ 396 | id: action.id, 397 | }), 398 | ); 399 | } 400 | 401 | override async addEthereumChain(action: AddEthereumChain): Promise { 402 | if (action.action !== "approve") { 403 | throw "not implemented"; 404 | } 405 | 406 | await this.instruct( 407 | new AddEthereumChainTemplate({ 408 | id: action.id, 409 | }), 410 | ); 411 | } 412 | 413 | override async requestAccounts(action: RequestAccounts): Promise { 414 | if (action.action !== "approve") { 415 | throw "not implemented"; 416 | } 417 | 418 | const list = document.createElement("ul"); 419 | for (const account of action.accounts) { 420 | const elem = document.createElement("li"); 421 | elem.innerText = account; 422 | list.appendChild(elem); 423 | } 424 | 425 | await this.instruct( 426 | new RequestAccountsTemplate({ 427 | id: action.id, 428 | accounts: list, 429 | }), 430 | ); 431 | } 432 | 433 | override async signMessage(action: SignMessage): Promise { 434 | if (action.action !== "approve") { 435 | throw "not implemented"; 436 | } 437 | 438 | await this.instruct( 439 | new SignMessageTemplate({ 440 | id: action.id, 441 | }), 442 | ); 443 | } 444 | 445 | override async sendTransaction(action: SendTransaction): Promise { 446 | if (action.action !== "approve") { 447 | throw "not implemented"; 448 | } 449 | 450 | await this.instruct( 451 | new SendTransactionTemplate({ 452 | id: action.id, 453 | }), 454 | ); 455 | } 456 | 457 | override async signTransaction(action: SignTransaction): Promise { 458 | if (action.action !== "approve") { 459 | throw "not implemented"; 460 | } 461 | 462 | await this.instruct( 463 | new SignTransactionTemplate({ 464 | id: action.id, 465 | }), 466 | ); 467 | } 468 | 469 | private async tryAddEthereumChain(action: ActivateChain): Promise { 470 | // MetaMask (and possibly others) display a switch chain prompt before 471 | // returning from `wallet_addEthereumChain`. To catch that prompt, we 472 | // have to listen to the switch event before even adding the chain. 473 | let switchActionPromise: unknown = null; 474 | const switchUnsubscribe = this.on("switchethereumchain", (ev) => { 475 | switchUnsubscribe(); 476 | assert.strictEqual( 477 | Number.parseInt(ev.chainId), 478 | Number.parseInt(action.chainId), 479 | `expected to switch to chain ${action.chainId},` + 480 | ` but got ${ev.chainId}`, 481 | ); 482 | 483 | switchActionPromise = this.switchEthereumChain({ 484 | id: ev.id, 485 | action: "approve", 486 | }); 487 | }); 488 | 489 | try { 490 | let addComplete = false; 491 | let eventComplete = false; 492 | 493 | const addPromise = this.wallet.wallet 494 | .addChain({ 495 | chain: { 496 | id: Number.parseInt(action.chainId), 497 | name: `Test Chain ${action.chainId}`, 498 | nativeCurrency: { 499 | name: "teth", 500 | symbol: "teth", 501 | decimals: 18, 502 | }, 503 | rpcUrls: { 504 | default: { http: [action.rpcUrl] }, 505 | public: { http: [action.rpcUrl] }, 506 | }, 507 | }, 508 | }) 509 | .finally(() => { 510 | addComplete = true; 511 | }); 512 | 513 | const eventPromise = this.next("addethereumchain").finally(() => { 514 | eventComplete = true; 515 | }); 516 | await Promise.race([addPromise, eventPromise]); 517 | 518 | if (addComplete) { 519 | await addPromise; 520 | } else { 521 | assert.ok(eventComplete, "Promise.race broken?"); 522 | } 523 | 524 | const addEvent = await eventPromise; 525 | 526 | assert.strictEqual( 527 | addEvent.rpcUrls.length, 528 | 1, 529 | `expected one RPC URL, but got ${addEvent.rpcUrls.length}`, 530 | ); 531 | 532 | assert.strictEqual( 533 | addEvent.rpcUrls[0], 534 | action.rpcUrl, 535 | `expected an RPC URL of "${action.rpcUrl}",` + 536 | ` but got "${addEvent.rpcUrls[0]}"`, 537 | ); 538 | 539 | assert.strictEqual( 540 | Number.parseInt(addEvent.chainId), 541 | Number.parseInt(action.chainId), 542 | `expected a chain id of ${action.chainId},` + 543 | ` but got ${addEvent.chainId}`, 544 | ); 545 | 546 | await this.addEthereumChain({ 547 | id: addEvent.id, 548 | action: "approve", 549 | }); 550 | 551 | await addPromise; 552 | 553 | const switchPromise = (async () => { 554 | let switched = false; 555 | do { 556 | try { 557 | await this.wallet.wallet.switchChain({ 558 | id: Number.parseInt(action.chainId), 559 | }); 560 | switched = true; 561 | } catch (e: unknown) { 562 | if (e instanceof Error && "error" in e) { 563 | if ( 564 | e.error instanceof Object && 565 | "code" in e.error 566 | ) { 567 | if (e.error.code === 4902) { 568 | await delay(1000); 569 | continue; 570 | } 571 | } 572 | } 573 | 574 | throw e; 575 | } 576 | } while (!switched); 577 | })(); 578 | 579 | await switchPromise; 580 | if (switchActionPromise instanceof Promise) { 581 | await switchActionPromise; 582 | } 583 | } finally { 584 | switchUnsubscribe(); 585 | } 586 | } 587 | 588 | override async activateChain(action: ActivateChain): Promise { 589 | try { 590 | await this.tryAddEthereumChain(action); 591 | return; 592 | } catch (e: unknown) { 593 | // wallet_addEthereumChain isn't exactly the safest endpoint, so we 594 | // don't expect wallets to implement it. We try optimistically but 595 | // fall back to human instructions if necessary. 596 | console.debug("`wallet_addEthereumChain` failed, going manual", e); 597 | } 598 | 599 | await this.instruct( 600 | new ActivateChainTemplate({ 601 | "chain-id": action.chainId, 602 | "rpc-url": action.rpcUrl, 603 | }), 604 | ); 605 | } 606 | 607 | private async instruct(template: Template): Promise { 608 | if (this.instructionsElement.children.length) { 609 | throw "previous instruction not completed"; 610 | } 611 | 612 | await new Promise((res, rej) => { 613 | const abort = document.createElement("button"); 614 | abort.innerText = "Abort"; 615 | abort.addEventListener("click", () => { 616 | this.instructionsElement.replaceChildren(); 617 | rej(); 618 | }); 619 | 620 | const complete = document.createElement("button"); 621 | complete.innerText = "Complete"; 622 | complete.addEventListener("click", () => { 623 | this.instructionsElement.replaceChildren(); 624 | res(); 625 | }); 626 | 627 | const instruct = new InstructTemplate({ 628 | content: template, 629 | abort, 630 | complete, 631 | }); 632 | 633 | this.instructionsElement.replaceChildren(instruct); 634 | }); 635 | } 636 | 637 | override report(action: Report): Promise { 638 | if (action.format !== "xunit") { 639 | throw new Error(`unknown report format ${action.format}`); 640 | } 641 | 642 | if (typeof action.value !== "string") { 643 | throw new Error(`unknown report value ${typeof action.value}`); 644 | } 645 | 646 | const dataUri = "data:application/xml;base64," + btoa(action.value); 647 | this.reportLinkElement.href = dataUri; 648 | 649 | this.reportElement.showModal(); 650 | 651 | return Promise.resolve(); 652 | } 653 | } 654 | 655 | export class WebSocketGlue extends Glue { 656 | private readonly client: WebSocketClient; 657 | 658 | public static async connect(address: string): Promise { 659 | const self = new WebSocketGlue(address); 660 | 661 | const open = new Promise((res, rej) => { 662 | try { 663 | self.client.once("open", res); 664 | } catch (e: unknown) { 665 | rej(e); 666 | } 667 | }); 668 | 669 | self.client.connect(); 670 | 671 | await open; 672 | 673 | for (const key of EVENTS) { 674 | self.client.on(key, (evt) => self.handle(key, evt)); 675 | await self.client.subscribe(key); 676 | } 677 | 678 | return self; 679 | } 680 | 681 | private constructor(address: string) { 682 | super(); 683 | this.client = new WebSocketClient(address); 684 | } 685 | 686 | private handle(key: keyof EventMap, evt: unknown): void { 687 | if (!evt || typeof evt !== "object") { 688 | throw new TypeError("Event argument not object"); 689 | } 690 | 691 | // TODO: Validate the event object is correct. 692 | 693 | /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument, 694 | @typescript-eslint/no-explicit-any */ 695 | this.emit(key, evt as any); 696 | } 697 | 698 | async activateChain(action: ActivateChain): Promise { 699 | await this.client.call("activateChain", [action]); 700 | } 701 | 702 | async requestAccounts(action: RequestAccounts): Promise { 703 | await this.client.call("requestAccounts", [action]); 704 | } 705 | 706 | async switchEthereumChain(action: SwitchEthereumChain): Promise { 707 | await this.client.call("switchEthereumChain", [action]); 708 | } 709 | 710 | async addEthereumChain(action: AddEthereumChain): Promise { 711 | await this.client.call("addEthereumChain", [action]); 712 | } 713 | 714 | async signMessage(action: SignMessage): Promise { 715 | await this.client.call("signMessage", [action]); 716 | } 717 | 718 | async sendTransaction(action: SendTransaction): Promise { 719 | await this.client.call("sendTransaction", [action]); 720 | } 721 | 722 | async signTransaction(action: SignTransaction): Promise { 723 | await this.client.call("signTransaction", [action]); 724 | } 725 | 726 | async report(action: Report): Promise { 727 | await this.client.call("report", [action]); 728 | } 729 | } 730 | -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- 1 | import { ManualGlue, WebSocketGlue } from "./glue"; 2 | import * as tests from "./tests"; 3 | import { retry, spawn } from "./util"; 4 | import { Glue } from "@wallet-test-framework/glue"; 5 | import { EthereumProvider } from "@walletconnect/ethereum-provider"; 6 | import "mocha/mocha.css"; 7 | import * as viem from "viem"; 8 | 9 | type Eip1193Provider = Parameters[0]; 10 | 11 | export interface AccountChain { 12 | provider: Eip1193Provider; 13 | public: viem.PublicClient; 14 | wallet: viem.WalletClient; 15 | } 16 | 17 | export type Chain = AccountChain; 18 | export type AnyChain = AccountChain; 19 | 20 | export interface TestChain extends Chain { 21 | test: viem.TestClient<"ganache", viem.Transport, viem.Chain>; 22 | } 23 | 24 | export interface WalletChain extends Chain { 25 | glue: Glue; 26 | } 27 | 28 | declare global { 29 | interface Window { 30 | ethereum: Eip1193Provider; 31 | } 32 | } 33 | 34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 35 | type Request = { method: string; params?: Array | Record }; 36 | 37 | class GanacheWorkerProvider implements Eip1193Provider { 38 | private worker: Worker; 39 | 40 | constructor(options: object) { 41 | const url = new URL("./worker_chain.js", import.meta.url); 42 | this.worker = new Worker(url); 43 | this.worker.addEventListener("error", (event) => { 44 | console.error("worker thread error", event); 45 | }); 46 | this.worker.addEventListener("messageerror", (event) => { 47 | console.error("worker thread message error", event); 48 | }); 49 | this.worker.postMessage(options); 50 | } 51 | 52 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 53 | public request(request: Request): Promise { 54 | return new Promise((res, rej) => { 55 | const channel = new MessageChannel(); 56 | channel.port1.onmessage = (evt) => { 57 | const data: unknown = evt.data; 58 | if (!data || typeof data !== "object") { 59 | return rej(); 60 | } 61 | 62 | if ("result" in data) { 63 | return res(data.result); 64 | } else if ("error" in data) { 65 | return rej(data.error); 66 | } else { 67 | return rej(); 68 | } 69 | }; 70 | channel.port1.onmessageerror = (evt) => rej(evt.data); 71 | this.worker.postMessage(request, [channel.port2]); 72 | }); 73 | } 74 | } 75 | 76 | async function getAccount(provider: Eip1193Provider): Promise<`0x${string}`> { 77 | const maybeAccounts: unknown = await provider.request({ 78 | method: "eth_accounts", 79 | params: [], 80 | }); 81 | if (!(maybeAccounts instanceof Array)) { 82 | throw new Error("invalid accounts response"); 83 | } 84 | if (typeof maybeAccounts[0] !== "string") { 85 | throw new Error("no account"); 86 | } 87 | const address: string = maybeAccounts[0]; 88 | if (!address.startsWith("0x")) { 89 | throw new Error("not an address"); 90 | } 91 | return address as `0x${string}`; 92 | } 93 | 94 | function main() { 95 | const connect = document.getElementById("connect"); 96 | const walletconnect = document.getElementById("walletConnect"); 97 | 98 | if (!connect) { 99 | throw "no #connect element"; 100 | } 101 | 102 | if (!walletconnect) { 103 | throw "no #walletConnect element"; 104 | } 105 | 106 | // Reload the page when the glue address changes. 107 | window.addEventListener("hashchange", () => window.location.reload()); 108 | 109 | let webSocket: WebSocket | null; 110 | const chainId = Math.floor(Math.random() * 32767) + 32767; 111 | 112 | const uuid = crypto.randomUUID(); 113 | const rpcUrl = new URL(`./rpc/${uuid}`, window.location.href); 114 | 115 | async function run(baseProvider: Parameters[0]) { 116 | if (webSocket) { 117 | throw "Already Connected"; 118 | } 119 | 120 | const options = { chainId: chainId }; 121 | const chain = { 122 | id: chainId, 123 | name: "Test Chain", 124 | network: "test-chain", 125 | nativeCurrency: { 126 | decimals: 18, 127 | name: "testETH", 128 | symbol: "teth", 129 | }, 130 | rpcUrls: { 131 | default: { http: [] }, 132 | public: { http: [] }, 133 | }, 134 | }; 135 | 136 | const provider = new GanacheWorkerProvider(options); 137 | 138 | const transport = viem.custom(provider); 139 | 140 | const blockchain: TestChain = { 141 | provider, 142 | wallet: viem.createWalletClient({ 143 | chain, 144 | transport, 145 | pollingInterval: 0, 146 | account: { 147 | address: await getAccount(provider), 148 | type: "json-rpc", 149 | } as const, 150 | }), 151 | public: viem.createPublicClient({ 152 | chain, 153 | transport, 154 | pollingInterval: 0, 155 | }), 156 | test: viem.createTestClient({ 157 | mode: "ganache", 158 | chain, 159 | transport, 160 | pollingInterval: 0, 161 | }), 162 | }; 163 | 164 | await blockchain.test.setAutomine(false); 165 | 166 | const unboundWallet: AccountChain = { 167 | provider: baseProvider, 168 | wallet: viem.createWalletClient({ 169 | chain, 170 | transport: viem.custom(baseProvider), 171 | pollingInterval: 0, 172 | }), 173 | public: viem.createPublicClient({ 174 | chain, 175 | transport: viem.custom(baseProvider), 176 | pollingInterval: 0, 177 | }), 178 | }; 179 | 180 | let glue: Glue; 181 | const config = new URLSearchParams(window.location.hash.slice(1)); 182 | const wsGlueAddress = config.get("glue"); 183 | 184 | if (wsGlueAddress) { 185 | glue = await WebSocketGlue.connect(wsGlueAddress); 186 | } else { 187 | const glueElem = document.getElementById("container"); 188 | if (!glueElem) { 189 | throw "no #container element"; 190 | } 191 | 192 | glue = new ManualGlue(glueElem, unboundWallet); 193 | } 194 | 195 | let requestAccountsPromise: unknown = null; 196 | const unsubscribe = glue.on("requestaccounts", (event) => { 197 | unsubscribe(); 198 | requestAccountsPromise = glue.requestAccounts({ 199 | action: "approve", 200 | id: event.id, 201 | accounts: [event.accounts[0]], 202 | }); 203 | }); 204 | 205 | if (baseProvider instanceof EthereumProvider) { 206 | await baseProvider.connect(); 207 | } 208 | 209 | const wsUrl = new URL(`./${uuid}`, window.location.href); 210 | wsUrl.protocol = wsUrl.protocol == "http:" ? "ws:" : "wss:"; 211 | wsUrl.hash = ""; 212 | 213 | webSocket = new WebSocket(wsUrl.href); 214 | 215 | webSocket.addEventListener( 216 | "message", 217 | spawn(async (event) => { 218 | if (typeof event.data !== "string") { 219 | throw new TypeError("WebSocket message not string"); 220 | } 221 | 222 | const msg: unknown = JSON.parse(event.data); 223 | console.log("received:", msg); 224 | 225 | if (!msg || typeof msg !== "object") { 226 | throw new TypeError("received message not object"); 227 | } 228 | 229 | if (!("body" in msg) || !msg.body) { 230 | throw new TypeError("'body' not in received message"); 231 | } 232 | 233 | if (typeof msg.body !== "object") { 234 | throw new TypeError("'body' not an object"); 235 | } 236 | 237 | let requests: Array; 238 | let batch: boolean; 239 | if (msg.body instanceof Array) { 240 | requests = msg.body; 241 | batch = true; 242 | } else { 243 | requests = [msg.body]; 244 | batch = false; 245 | } 246 | 247 | if (!("number" in msg)) { 248 | throw new TypeError("'number' not in message body"); 249 | } 250 | 251 | const responses = []; 252 | for (const request of requests) { 253 | const response: { [key: string]: unknown } = {}; 254 | 255 | if (!request || typeof request !== "object") { 256 | throw new TypeError("received request not object"); 257 | } 258 | 259 | if ("id" in request) { 260 | response.id = request.id; 261 | } 262 | 263 | if (!("method" in request)) { 264 | throw new TypeError("'method' not in request"); 265 | } 266 | 267 | if (typeof request.method !== "string") { 268 | throw new TypeError("request 'method' not a string"); 269 | } 270 | 271 | let params: unknown[] | object; 272 | if ("params" in request) { 273 | if ( 274 | !request.params || 275 | typeof request.params !== "object" 276 | ) { 277 | throw new Error( 278 | "request 'params' not an array or object", 279 | ); 280 | } 281 | params = request.params; 282 | } else { 283 | params = []; 284 | } 285 | 286 | try { 287 | response.result = await blockchain.provider.request({ 288 | method: request.method, 289 | params, 290 | }); 291 | } catch (error: unknown) { 292 | response.error = error; 293 | } 294 | 295 | responses.push({ jsonrpc: "2.0", ...response }); 296 | } 297 | 298 | const result = batch ? responses : responses[0]; 299 | 300 | webSocket?.send( 301 | JSON.stringify({ 302 | number: msg.number, 303 | result, 304 | }), 305 | ); 306 | }), 307 | ); 308 | 309 | const open = spawn(async () => { 310 | webSocket?.removeEventListener("open", open); 311 | 312 | await glue.activateChain({ 313 | chainId: "0x" + chainId.toString(16), 314 | rpcUrl: rpcUrl.href, 315 | }); 316 | 317 | await unboundWallet.wallet.requestAddresses(); 318 | unsubscribe(); 319 | if (requestAccountsPromise instanceof Promise) { 320 | await requestAccountsPromise; 321 | } 322 | 323 | await retry({ 324 | totalMillis: 10 * 60 * 1000, 325 | operation: async () => { 326 | const walletChain = await unboundWallet.public.getChainId(); 327 | if (chainId === walletChain) { 328 | return; 329 | } 330 | 331 | console.log( 332 | "switching from chain id", 333 | walletChain, 334 | "to", 335 | chainId, 336 | ); 337 | 338 | await unboundWallet.wallet.switchChain({ id: chainId }); 339 | // TODO: This likely will need a glue event. 340 | 341 | throw new Error( 342 | `want chain ${chainId} but got chain ${walletChain}`, 343 | ); 344 | }, 345 | }); 346 | 347 | const wallet: WalletChain = { 348 | provider: baseProvider, 349 | wallet: viem.createWalletClient({ 350 | chain, 351 | transport: viem.custom(baseProvider), 352 | account: { 353 | address: await getAccount(baseProvider), 354 | type: "json-rpc", 355 | } as viem.Account, 356 | pollingInterval: 0, 357 | }), 358 | public: viem.createPublicClient({ 359 | chain, 360 | transport: viem.custom(baseProvider), 361 | pollingInterval: 0, 362 | }), 363 | glue, 364 | }; 365 | 366 | const report = await tests.run(blockchain, wallet); 367 | await glue.report(report); 368 | }); 369 | 370 | webSocket.addEventListener("open", open); 371 | } 372 | 373 | connect.addEventListener( 374 | "click", 375 | spawn(async () => await run(window.ethereum)), 376 | ); 377 | 378 | walletconnect.addEventListener( 379 | "click", 380 | spawn(async () => { 381 | const rpcMap: { [key: string]: string } = {}; 382 | rpcMap[chainId.toString()] = rpcUrl.href; 383 | const provider = await EthereumProvider.init({ 384 | projectId: "bb26a8cd7dd09815be4295a0b57c3819", 385 | metadata: { 386 | name: "Wallet Test Framework", 387 | description: "Wallet Test Framework", 388 | url: "https://wallet-test-framework.herokuapp.com/", // origin must match your domain & subdomain 389 | icons: [ 390 | "https://raw.githubusercontent.com/wallet-test-framework/framework/master/docs/img/logo.svg", 391 | ], 392 | }, 393 | showQrModal: true, 394 | optionalChains: [chainId], 395 | 396 | rpcMap: rpcMap, 397 | }); 398 | 399 | await run(provider); 400 | }), 401 | ); 402 | } 403 | 404 | main(); 405 | -------------------------------------------------------------------------------- /client/src/mocha-browser.d.ts: -------------------------------------------------------------------------------- 1 | interface BrowserMocha { 2 | throwError(err: any): never; 3 | setup(opts?: Mocha.Interface | Mocha.MochaOptions): this; 4 | } 5 | 6 | declare namespace Mocha { 7 | namespace utils { 8 | function escape(value?: unknown): string; 9 | } 10 | } 11 | 12 | declare module "mocha/mocha.js" { 13 | import Mocha from "mocha"; 14 | const _: Mocha & BrowserMocha; 15 | export default _; 16 | } 17 | -------------------------------------------------------------------------------- /client/src/reporter.ts: -------------------------------------------------------------------------------- 1 | // Copyright © 2011-2022 OpenJS Foundation and contributors, https://openjsf.org 2 | // Copyright © 2024 Binary Cake Ltd. & Contributors 3 | // 4 | import mocha from "mocha/mocha.js"; 5 | 6 | const EVENT_TEST_PASS = Mocha.Runner.constants.EVENT_TEST_PASS; 7 | const EVENT_TEST_PENDING = Mocha.Runner.constants.EVENT_TEST_PENDING; 8 | const EVENT_TEST_FAIL = Mocha.Runner.constants.EVENT_TEST_FAIL; 9 | const EVENT_RUN_END = Mocha.Runner.constants.EVENT_RUN_END; 10 | const STATE_FAILED = "failed"; 11 | const escape = Mocha.utils.escape; 12 | 13 | function showDiff(err: unknown): err is { actual: string; expected: string } { 14 | if (!err || typeof err !== "object") { 15 | return false; 16 | } 17 | 18 | if ("showDiff" in err && err.showDiff === false) { 19 | return false; 20 | } 21 | 22 | if (!("actual" in err && "expected" in err)) { 23 | return false; 24 | } 25 | 26 | return typeof err.actual === "string" && typeof err.expected === "string"; 27 | } 28 | 29 | export abstract class HtmlXUnit extends Mocha.reporters.HTML { 30 | private _report: string = ""; 31 | 32 | constructor(runner: Mocha.Runner, options: Mocha.MochaOptions) { 33 | super(runner, options); 34 | 35 | // TODO: Figure out what's wrong with my typescript bindings that makes 36 | // importing mocha without using it necessary. 37 | void mocha.setup; 38 | 39 | const stats = this.stats; 40 | const tests: Mocha.Test[] = []; 41 | 42 | // the name of the test suite, as it will appear in the resulting XML file 43 | const suiteName = "Wallet Test Framework"; 44 | 45 | runner.on(EVENT_TEST_PENDING, (test: Mocha.Test) => { 46 | tests.push(test); 47 | }); 48 | 49 | runner.on(EVENT_TEST_PASS, (test: Mocha.Test) => { 50 | tests.push(test); 51 | }); 52 | 53 | runner.on(EVENT_TEST_FAIL, (test: Mocha.Test) => { 54 | tests.push(test); 55 | }); 56 | 57 | runner.once(EVENT_RUN_END, () => { 58 | const skipped = ( 59 | stats.tests - 60 | stats.failures - 61 | stats.passes 62 | ).toString(); 63 | const data = { 64 | name: suiteName, 65 | tests: stats.tests.toString(), 66 | failures: "0", 67 | errors: stats.failures.toString(), 68 | skipped, 69 | timestamp: new Date().toUTCString(), 70 | time: "0", 71 | }; 72 | if (typeof stats.duration !== "undefined") { 73 | data.time = (stats.duration / 1000).toString(); 74 | } 75 | this.write(this.tag("testsuite", data, false)); 76 | 77 | tests.forEach((t) => { 78 | this.test(t); 79 | }); 80 | 81 | this.write(""); 82 | this.report(this._report); 83 | this._report = ""; 84 | }); 85 | } 86 | 87 | protected abstract report(report: string): void; 88 | 89 | done(failures: number, fn?: (failures: number) => void): void { 90 | if (fn) { 91 | fn(failures); 92 | } 93 | } 94 | 95 | private write(line: string) { 96 | this._report += line + "\n"; 97 | } 98 | 99 | private test(test: Mocha.Test) { 100 | const attrs = { 101 | classname: test.parent?.fullTitle() || "", 102 | name: test.title, 103 | time: ((test?.duration || 0) / 1000).toString(), 104 | }; 105 | 106 | if (test.state === STATE_FAILED) { 107 | const generateDiff = Mocha.reporters.Base.generateDiff; 108 | const err = test.err || {}; 109 | const diff = showDiff(err) 110 | ? "\n" + generateDiff(err.actual, err.expected) 111 | : ""; 112 | const message = "message" in err ? err.message : ""; 113 | const stack = "stack" in err ? err.stack : ""; 114 | this.write( 115 | this.tag( 116 | "testcase", 117 | attrs, 118 | false, 119 | this.tag( 120 | "failure", 121 | {}, 122 | false, 123 | escape(message) + escape(diff) + "\n" + escape(stack), 124 | ), 125 | ), 126 | ); 127 | } else if (test.isPending()) { 128 | this.write( 129 | this.tag( 130 | "testcase", 131 | attrs, 132 | false, 133 | this.tag("skipped", {}, true), 134 | ), 135 | ); 136 | } else { 137 | this.write(this.tag("testcase", attrs, true)); 138 | } 139 | } 140 | 141 | private tag( 142 | name: string, 143 | attrs: { [_: string]: string }, 144 | close: boolean, 145 | content?: string, 146 | ) { 147 | const end = close ? "/>" : ">"; 148 | const pairs = []; 149 | let tag; 150 | 151 | for (const key in attrs) { 152 | if (Object.prototype.hasOwnProperty.call(attrs, key)) { 153 | pairs.push(key + '="' + escape(attrs[key]) + '"'); 154 | } 155 | } 156 | 157 | tag = "<" + name + (pairs.length ? " " + pairs.join(" ") : "") + end; 158 | if (content) { 159 | tag += content + "; 3 | export default _; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/tests.ts: -------------------------------------------------------------------------------- 1 | import { TestChain, WalletChain } from "./index"; 2 | import { HtmlXUnit } from "./reporter"; 3 | import { Report } from "@wallet-test-framework/glue"; 4 | import mocha from "mocha/mocha.js"; 5 | 6 | export let wallet: WalletChain | null; 7 | export let blockchain: TestChain | null; 8 | 9 | export async function run( 10 | myBlockchain: TestChain, 11 | myWallet: WalletChain, 12 | ): Promise { 13 | wallet = myWallet; 14 | blockchain = myBlockchain; 15 | 16 | let completedReport: string | undefined; 17 | 18 | class MyHtmlXUnit extends HtmlXUnit { 19 | protected override report(report: string): void { 20 | completedReport = report; 21 | } 22 | } 23 | 24 | mocha.setup({ 25 | ui: "bdd", 26 | timeout: 10 * 60 * 1000, 27 | slow: 60100, 28 | reporter: MyHtmlXUnit, 29 | }); 30 | 31 | await import("./tests/eth/accounts"); 32 | await import("./tests/eth/blockNumber"); 33 | await import("./tests/eth/call"); 34 | await import("./tests/eth/chainId"); 35 | await import("./tests/eth/createAccessList"); 36 | await import("./tests/eth/estimateGas"); 37 | await import("./tests/eth/feeHistory"); 38 | await import("./tests/eth/gasPrice"); 39 | await import("./tests/eth/getBalance"); 40 | await import("./tests/eth/getBlockByHash"); 41 | await import("./tests/eth/getBlockByNumber"); 42 | await import("./tests/eth/getBlockTransactionCountByHash"); 43 | await import("./tests/eth/getBlockTransactionCountByNumber"); 44 | await import("./tests/eth/getCode"); 45 | await import("./tests/eth/getFilterChanges"); 46 | await import("./tests/eth/getFilterLogs"); 47 | await import("./tests/eth/getLogs"); 48 | await import("./tests/eth/getProof"); 49 | await import("./tests/eth/getStorageAt"); 50 | await import("./tests/eth/getTransactionByBlockHashAndIndex"); 51 | await import("./tests/eth/getTransactionByBlockNumberAndIndex"); 52 | await import("./tests/eth/getTransactionByHash"); 53 | await import("./tests/eth/getTransactionCount"); 54 | await import("./tests/eth/getTransactionReceipt"); 55 | await import("./tests/eth/maxPriorityFeePerGas"); 56 | await import("./tests/eth/newBlockFilter"); 57 | await import("./tests/eth/newFilter"); 58 | await import("./tests/eth/newPendingTransactionFilter"); 59 | await import("./tests/eth/sendRawTransaction"); 60 | await import("./tests/eth/sendTransaction"); 61 | await import("./tests/eth/sign"); 62 | await import("./tests/eth/signTransaction"); 63 | await import("./tests/eth/uninstallFilter"); 64 | 65 | const result = new Promise((res, rej) => { 66 | try { 67 | mocha.run((_failures: number) => { 68 | if (!completedReport) { 69 | throw new Error("no report generated"); 70 | } 71 | 72 | res({ 73 | format: "xunit", 74 | value: completedReport, 75 | }); 76 | }); 77 | } catch (e) { 78 | rej(e); 79 | } 80 | }); 81 | 82 | return await result; 83 | } 84 | -------------------------------------------------------------------------------- /client/src/tests/eth/accounts.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("accounts", () => { 12 | it("returns a list of accounts", async () => { 13 | const accounts = await wallet.wallet.getAddresses(); 14 | 15 | for (const account of accounts) { 16 | assert.ok(account.startsWith("0x"), "account is a hex string"); 17 | } 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /client/src/tests/eth/blockNumber.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import { retry } from "../../util"; 3 | import assert from "assert"; 4 | 5 | const blockchain = tests.blockchain; 6 | const wallet = tests.wallet; 7 | 8 | if (!blockchain || !wallet) { 9 | throw "not ready"; 10 | } 11 | 12 | describe("blockNumber", () => { 13 | it("block number from wallet and ganache are the same", async () => { 14 | const walletInitalBlockNumber = await wallet.public.getBlockNumber(); 15 | const ganacheInitalBlockNumber = 16 | await blockchain.public.getBlockNumber(); 17 | 18 | assert.equal( 19 | walletInitalBlockNumber, 20 | ganacheInitalBlockNumber, 21 | "initalBlockNumber", 22 | ); 23 | 24 | await blockchain.test.mine({ blocks: 5000 }); 25 | 26 | const ganacheFinalBlockNumber = 27 | await blockchain.public.getBlockNumber(); 28 | 29 | const expected = walletInitalBlockNumber + 5000n; 30 | assert.equal( 31 | ganacheFinalBlockNumber, 32 | expected, 33 | `blockchain's final block number (${ganacheFinalBlockNumber}) equals` + 34 | ` wallet's initial block number plus number mined (${expected})`, 35 | ); 36 | 37 | await retry(async () => { 38 | const walletFinalBlockNumber = await wallet.public.getBlockNumber(); 39 | 40 | assert.equal( 41 | walletFinalBlockNumber, 42 | ganacheFinalBlockNumber, 43 | `wallet's final block number (${walletFinalBlockNumber}) equals` + 44 | ` blockchain's final block number (${ganacheFinalBlockNumber})`, 45 | ); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /client/src/tests/eth/call.ts: -------------------------------------------------------------------------------- 1 | import { CALL_ABI, CALL_BYTECODE } from "../../contracts/call.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | import * as viem from "viem"; 5 | 6 | const blockchain = tests.blockchain; 7 | const wallet = tests.wallet; 8 | 9 | if (!blockchain || !wallet) { 10 | throw "not ready"; 11 | } 12 | 13 | describe("call", () => { 14 | let contract0: viem.GetContractReturnType< 15 | typeof CALL_ABI, 16 | { public: typeof wallet.public; wallet: typeof wallet.wallet } 17 | >; 18 | 19 | before(async () => { 20 | const contractHash0 = await blockchain.wallet.deployContract({ 21 | abi: CALL_ABI, 22 | bytecode: CALL_BYTECODE, 23 | gas: 150000n, 24 | }); 25 | 26 | await blockchain.test.mine({ blocks: 1 }); 27 | 28 | const receipt0 = await blockchain.public.waitForTransactionReceipt({ 29 | hash: contractHash0, 30 | }); 31 | 32 | if (receipt0.status !== "success") { 33 | throw new Error(`not deployed: ${receipt0.status}`); 34 | } 35 | 36 | const address0 = receipt0.contractAddress; 37 | 38 | if (address0 == null) { 39 | throw "not deployed"; 40 | } 41 | 42 | contract0 = viem.getContract({ 43 | client: { public: wallet.public, wallet: wallet.wallet }, 44 | address: address0, 45 | abi: CALL_ABI, 46 | }); 47 | }); 48 | 49 | it("returns the result of calling a contract", async () => { 50 | const call = await contract0.read.add1([1234n]); 51 | 52 | assert.equal(call, 1235n); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /client/src/tests/eth/chainId.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const wallet = tests.wallet; 5 | const blockchain = tests.blockchain; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("chainId", () => { 12 | it("returns the same chain Id", async () => { 13 | const walletChainId = await wallet.public.getChainId(); 14 | const ganacheChainId = await blockchain.public.getChainId(); 15 | 16 | assert.equal(walletChainId, ganacheChainId); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /client/src/tests/eth/createAccessList.ts: -------------------------------------------------------------------------------- 1 | import { STORE_ABI, STORE_BYTECODE } from "../../contracts/getStorageAt.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | 5 | const blockchain = tests.blockchain; 6 | const wallet = tests.wallet; 7 | 8 | if (!blockchain || !wallet) { 9 | throw "not ready"; 10 | } 11 | 12 | describe("createAccessList", () => { 13 | let contractAddress: `0x${string}`; 14 | 15 | before(async () => { 16 | const contractHash = await blockchain.wallet.deployContract({ 17 | abi: STORE_ABI, 18 | bytecode: STORE_BYTECODE, 19 | gas: 150000n, 20 | }); 21 | 22 | await blockchain.test.mine({ blocks: 1 }); 23 | 24 | const address = ( 25 | await blockchain.public.waitForTransactionReceipt({ 26 | hash: contractHash, 27 | }) 28 | ).contractAddress; 29 | if (address == null) { 30 | throw "not deployed"; 31 | } 32 | contractAddress = address; 33 | }); 34 | 35 | xit("returns the same access list as the client", async () => { 36 | const sender = (await blockchain.wallet.getAddresses())[0]; 37 | const request = { 38 | method: "eth_createAccessList", 39 | params: [ 40 | { 41 | nonce: "0x01", 42 | to: contractAddress, 43 | from: sender, 44 | gas: "0x7530", 45 | value: "0x00", 46 | // store (300) 47 | input: "0x6057361d000000000000000000000000000000000000000000000000000000000000012c", 48 | }, 49 | ], 50 | }; 51 | 52 | const fromBlockchain: unknown = 53 | await blockchain.provider.request(request); 54 | const fromWallet: unknown = await wallet.provider.request(request); 55 | 56 | assert.deepEqual(fromBlockchain, fromWallet); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /client/src/tests/eth/estimateGas.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("estimateGas", () => { 12 | it("estimates the amount of gas used for a transaction", async () => { 13 | const src = (await blockchain.wallet.getAddresses())[0]; 14 | const dest = (await wallet.wallet.getAddresses())[0]; 15 | 16 | const fromWallet = await wallet.public.estimateGas({ 17 | account: src, 18 | to: dest, 19 | value: 0n, 20 | }); 21 | 22 | const fromGanache = await blockchain.public.estimateGas({ 23 | account: src, 24 | to: dest, 25 | value: 0n, 26 | }); 27 | 28 | assert.equal(fromWallet, fromGanache); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /client/src/tests/eth/feeHistory.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("feeHistory", () => { 12 | it("returns the same fee history as the client", async () => { 13 | await blockchain.test.mine({ blocks: 2 }); 14 | 15 | const fromWallet = await wallet.public.getFeeHistory({ 16 | blockCount: 2, 17 | rewardPercentiles: [25, 75], 18 | }); 19 | 20 | const fromGanache = await blockchain.public.getFeeHistory({ 21 | blockCount: 2, 22 | rewardPercentiles: [25, 75], 23 | }); 24 | 25 | assert.deepEqual(fromWallet, fromGanache); 26 | assert.equal(2, fromWallet.reward?.length); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /client/src/tests/eth/gasPrice.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("gasPrice", () => { 12 | it("returns the same gas price as the client", async () => { 13 | await blockchain.test.mine({ blocks: 2 }); 14 | 15 | const fromWallet = await wallet.public.getGasPrice(); 16 | const fromGanache = await blockchain.public.getGasPrice(); 17 | 18 | assert.equal(fromWallet, fromGanache); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /client/src/tests/eth/getBalance.ts: -------------------------------------------------------------------------------- 1 | import { RECEIVE_ABI, RECEIVE_BYTECODE } from "../../contracts/getBalance.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | import * as viem from "viem"; 5 | 6 | const blockchain = tests.blockchain; 7 | const wallet = tests.wallet; 8 | 9 | if (!blockchain || !wallet) { 10 | throw "not ready"; 11 | } 12 | 13 | describe("getBalance", () => { 14 | let contractAddress: `0x${string}`; 15 | let contract: viem.GetContractReturnType< 16 | typeof RECEIVE_ABI, 17 | { 18 | public: typeof blockchain.public; 19 | wallet: typeof blockchain.wallet; 20 | } 21 | >; 22 | 23 | before(async () => { 24 | const contractHash = await blockchain.wallet.deployContract({ 25 | abi: RECEIVE_ABI, 26 | bytecode: RECEIVE_BYTECODE, 27 | }); 28 | 29 | await blockchain.test.mine({ blocks: 1 }); 30 | 31 | const address = ( 32 | await blockchain.public.waitForTransactionReceipt({ 33 | hash: contractHash, 34 | }) 35 | ).contractAddress; 36 | if (address == null) { 37 | throw "not deployed"; 38 | } 39 | contractAddress = address; 40 | contract = viem.getContract({ 41 | client: { 42 | public: blockchain.public, 43 | wallet: blockchain.wallet, 44 | }, 45 | address, 46 | abi: RECEIVE_ABI, 47 | }); 48 | }); 49 | 50 | it("sending ether to address increases balance", async () => { 51 | const src = (await blockchain.wallet.getAddresses())[0]; 52 | const dest = (await wallet.wallet.getAddresses())[0]; 53 | 54 | const balance = 0x100000000000000000000n; 55 | await blockchain.test.setBalance({ address: src, value: balance }); 56 | 57 | const walletInitalBalance = await wallet.public.getBalance({ 58 | address: dest, 59 | }); 60 | const ganacheInitalBalance = await blockchain.public.getBalance({ 61 | address: dest, 62 | }); 63 | 64 | assert.equal( 65 | walletInitalBalance.toString(), 66 | ganacheInitalBalance.toString(), 67 | "initalBalance", 68 | ); 69 | 70 | const value = 1n; 71 | const response = await blockchain.wallet.sendTransaction({ 72 | account: src, 73 | to: dest, 74 | value: value, 75 | }); 76 | 77 | await blockchain.test.mine({ blocks: 5000 }); 78 | await wallet.public.waitForTransactionReceipt({ hash: response }); 79 | 80 | const walletFinalBalance = await wallet.public.getBalance({ 81 | address: dest, 82 | }); 83 | const ganacheFinalBalance = await blockchain.public.getBalance({ 84 | address: dest, 85 | }); 86 | 87 | assert.equal( 88 | walletFinalBalance.toString(), 89 | ganacheFinalBalance.toString(), 90 | "finalBalance", 91 | ); 92 | 93 | const expected = value + walletInitalBalance; 94 | 95 | assert.equal(walletFinalBalance, expected); 96 | }); 97 | 98 | it("sending ether to contract increases balance", async () => { 99 | const src = (await blockchain.wallet.getAddresses())[0]; 100 | 101 | const balance = 0x100000000000000000000n; 102 | await blockchain.test.setBalance({ address: src, value: balance }); 103 | 104 | const walletInitalBalance = await wallet.public.getBalance({ 105 | address: contractAddress, 106 | }); 107 | const ganacheInitalBalance = await blockchain.public.getBalance({ 108 | address: contractAddress, 109 | }); 110 | 111 | assert.equal( 112 | walletInitalBalance.toString(), 113 | ganacheInitalBalance.toString(), 114 | "initalBalance", 115 | ); 116 | 117 | const value = 1n; 118 | const response = await contract.write.give({ value }); 119 | 120 | await blockchain.test.mine({ blocks: 5000 }); 121 | 122 | await wallet.public.waitForTransactionReceipt({ hash: response }); 123 | 124 | const walletFinalBalance = await wallet.public.getBalance({ 125 | address: contractAddress, 126 | }); 127 | const ganacheFinalBalance = await blockchain.public.getBalance({ 128 | address: contractAddress, 129 | }); 130 | 131 | assert.equal( 132 | walletFinalBalance.toString(), 133 | ganacheFinalBalance.toString(), 134 | "finalBalance", 135 | ); 136 | 137 | const expected = value + walletInitalBalance; 138 | 139 | assert.equal(walletFinalBalance, expected); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /client/src/tests/eth/getBlockByHash.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("getBlockByHash", () => { 12 | it("returns the mined block", async () => { 13 | const dest = wallet.wallet.account; 14 | 15 | const value = 0n; 16 | const response = await blockchain.wallet.sendTransaction({ 17 | to: dest.address, 18 | value: value, 19 | }); 20 | 21 | const blockNumber = await blockchain.public.getBlockNumber(); 22 | 23 | await blockchain.test.mine({ blocks: 1 }); 24 | await blockchain.public.waitForTransactionReceipt({ hash: response }); 25 | 26 | const blockHash = ( 27 | await blockchain.public.getBlock({ blockNumber: blockNumber + 1n }) 28 | ).hash; 29 | if (!blockHash) { 30 | throw "no block hash"; 31 | } 32 | 33 | const walletBlockByHash = await wallet.public.getBlock({ blockHash }); 34 | 35 | assert.equal(walletBlockByHash.transactions[0], response); 36 | }); 37 | 38 | it("behaves well when the block doesn't exist", async () => { 39 | const blockHash = 40 | "0x0000000000000000000000000000000000000000000000000000000000000000"; 41 | 42 | await assert.rejects(wallet.public.getBlock({ blockHash })); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /client/src/tests/eth/getBlockByNumber.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const wallet = tests.wallet; 5 | const blockchain = tests.blockchain; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("getBlockByNumber", () => { 12 | it("returns the mined block", async () => { 13 | const dest = wallet.wallet.account; 14 | 15 | const value = 0n; 16 | const response = await blockchain.wallet.sendTransaction({ 17 | to: dest.address, 18 | value: value, 19 | }); 20 | 21 | const blockNumber = await blockchain.public.getBlockNumber(); 22 | 23 | await blockchain.test.mine({ blocks: 1 }); 24 | await blockchain.public.waitForTransactionReceipt({ hash: response }); 25 | 26 | const walletBlockByNumber = await wallet.public.getBlock({ 27 | blockNumber: blockNumber + 1n, 28 | }); 29 | 30 | assert.equal(walletBlockByNumber.transactions[0], response); 31 | }); 32 | 33 | it("behaves well when the block doesn't exist", async () => { 34 | const blockNumber = 35 | 0x0000000000000f00000000000000000000000000000000000000000000000000n; 36 | 37 | await assert.rejects( 38 | wallet.public.getBlock({ 39 | blockNumber, 40 | }), 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /client/src/tests/eth/getBlockTransactionCountByHash.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("getBlockTransactionCountByHash", () => { 12 | it("returns zero for empty block", async () => { 13 | const blockNumber = await blockchain.public.getBlockNumber(); 14 | 15 | await blockchain.test.mine({ blocks: 1 }); 16 | 17 | const blockHash = ( 18 | await blockchain.public.getBlock({ blockNumber: blockNumber + 1n }) 19 | ).hash; 20 | if (!blockHash) { 21 | throw "no block hash"; 22 | } 23 | 24 | const count = await wallet.public.getBlockTransactionCount({ 25 | blockHash, 26 | }); 27 | 28 | assert.equal(count, 0); 29 | }); 30 | 31 | it("returns the correct count of transactions", async () => { 32 | const dest = wallet.wallet.account.address; 33 | 34 | // Create a block with one transaction 35 | const value = 0n; 36 | let response = await blockchain.wallet.sendTransaction({ 37 | to: dest, 38 | value: value, 39 | }); 40 | 41 | const blockNumber = await blockchain.public.getBlockNumber(); 42 | 43 | await blockchain.test.mine({ blocks: 1 }); 44 | const mined0 = await blockchain.public.waitForTransactionReceipt({ 45 | hash: response, 46 | }); 47 | 48 | const blockHash0 = ( 49 | await blockchain.public.getBlock({ blockNumber: blockNumber + 1n }) 50 | ).hash; 51 | if (!blockHash0) { 52 | throw "no block hash"; 53 | } 54 | 55 | assert.equal( 56 | mined0?.blockHash, 57 | blockHash0, 58 | `first transaction block ${mined0?.blockNumber ?? ""} (${ 59 | mined0?.blockHash ?? "" 60 | }) matches mined block ${blockNumber + 1n} (${blockHash0})`, 61 | ); 62 | 63 | // Create a block with two transactions 64 | await blockchain.wallet.sendTransaction({ 65 | to: dest, 66 | value: value, 67 | }); 68 | 69 | response = await blockchain.wallet.sendTransaction({ 70 | to: dest, 71 | value: value, 72 | }); 73 | 74 | await blockchain.test.mine({ blocks: 1 }); 75 | const mined1 = await blockchain.public.waitForTransactionReceipt({ 76 | hash: response, 77 | }); 78 | 79 | const blockHash1 = ( 80 | await blockchain.public.getBlock({ blockNumber: blockNumber + 2n }) 81 | ).hash; 82 | if (!blockHash1) { 83 | throw "no block hash"; 84 | } 85 | 86 | assert.equal( 87 | mined1?.blockHash, 88 | blockHash1, 89 | `third transaction block ${mined1?.blockNumber ?? ""} (${ 90 | mined1?.blockHash ?? "" 91 | })` + ` matches mined block ${blockNumber + 2n} (${blockHash1})`, 92 | ); 93 | 94 | const count0 = await wallet.public.getBlockTransactionCount({ 95 | blockHash: blockHash0, 96 | }); 97 | 98 | assert.equal(count0, 1n); 99 | 100 | const count1 = await wallet.public.getBlockTransactionCount({ 101 | blockHash: blockHash1, 102 | }); 103 | 104 | assert.equal(count1, 2n); 105 | }); 106 | 107 | it("behaves when given a nonexistant block", async () => { 108 | await assert.rejects( 109 | wallet.public.getBlockTransactionCount({ 110 | blockHash: 111 | "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 112 | }), 113 | ); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /client/src/tests/eth/getBlockTransactionCountByNumber.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("getBlockTransactionCountByNumber", () => { 12 | it("returns zero for empty block", async () => { 13 | const blockNumber = await blockchain.public.getBlockNumber(); 14 | 15 | await blockchain.test.mine({ blocks: 1 }); 16 | 17 | const count = await wallet.public.getBlockTransactionCount({ 18 | blockNumber: blockNumber + 1n, 19 | }); 20 | 21 | assert.equal(count, 0n); 22 | }); 23 | it("returns the correct count of transactions", async () => { 24 | const dest = wallet.wallet.account.address; 25 | 26 | // Create a block with one transaction 27 | const value = 0n; 28 | let response = await blockchain.wallet.sendTransaction({ 29 | to: dest, 30 | value: value, 31 | }); 32 | 33 | const blockNumber = await blockchain.public.getBlockNumber(); 34 | 35 | await blockchain.test.mine({ blocks: 1 }); 36 | await blockchain.public.waitForTransactionReceipt({ hash: response }); 37 | 38 | // Create a block with two transactions 39 | await blockchain.wallet.sendTransaction({ 40 | to: dest, 41 | value: value, 42 | }); 43 | 44 | response = await blockchain.wallet.sendTransaction({ 45 | to: dest, 46 | value: value, 47 | }); 48 | 49 | await blockchain.test.mine({ blocks: 1 }); 50 | const mined = await blockchain.public.waitForTransactionReceipt({ 51 | hash: response, 52 | }); 53 | 54 | assert.equal( 55 | mined?.blockNumber, 56 | blockNumber + 2n, 57 | `transaction block (${ 58 | mined?.blockNumber ?? "" 59 | }) matches mined block (${blockNumber + 2n})`, 60 | ); 61 | 62 | const count0 = await wallet.public.getBlockTransactionCount({ 63 | blockNumber: blockNumber + 1n, 64 | }); 65 | 66 | assert.equal(count0, 1n); 67 | 68 | const count1 = await wallet.public.getBlockTransactionCount({ 69 | blockNumber: blockNumber + 2n, 70 | }); 71 | 72 | assert.equal(count1, 2n); 73 | }); 74 | it("behaves when given a nonexistent block", async () => { 75 | await assert.rejects( 76 | wallet.public.getBlockTransactionCount({ 77 | blockNumber: 78 | 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn, 79 | }), 80 | ); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /client/src/tests/eth/getCode.ts: -------------------------------------------------------------------------------- 1 | import { RECEIVE_ABI, RECEIVE_BYTECODE } from "../../contracts/getBalance.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | 5 | const blockchain = tests.blockchain; 6 | const wallet = tests.wallet; 7 | 8 | if (!blockchain || !wallet) { 9 | throw "not ready"; 10 | } 11 | 12 | describe("getCode", () => { 13 | let contractAddress: `0x${string}`; 14 | 15 | before(async () => { 16 | const contractHash = await blockchain.wallet.deployContract({ 17 | abi: RECEIVE_ABI, 18 | bytecode: RECEIVE_BYTECODE, 19 | }); 20 | 21 | await blockchain.test.mine({ blocks: 1 }); 22 | 23 | const address = ( 24 | await blockchain.public.waitForTransactionReceipt({ 25 | hash: contractHash, 26 | }) 27 | ).contractAddress; 28 | if (address == null) { 29 | throw "not deployed"; 30 | } 31 | contractAddress = address; 32 | }); 33 | 34 | it("returns the bytecode of a contract", async () => { 35 | const walletBytecode = await wallet.public.getBytecode({ 36 | address: contractAddress, 37 | }); 38 | const blockchainBytecode = await blockchain.public.getBytecode({ 39 | address: contractAddress, 40 | }); 41 | 42 | assert.equal(walletBytecode, blockchainBytecode); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /client/src/tests/eth/getFilterChanges.ts: -------------------------------------------------------------------------------- 1 | import { EMIT_ABI, EMIT_BYTECODE } from "../../contracts/newFilter.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | import * as viem from "viem"; 5 | 6 | const blockchain = tests.blockchain; 7 | const wallet = tests.wallet; 8 | 9 | if (!blockchain || !wallet) { 10 | throw "not ready"; 11 | } 12 | 13 | describe("getFilterChanges", () => { 14 | let contract0: viem.GetContractReturnType< 15 | typeof EMIT_ABI, 16 | { 17 | public: typeof blockchain.public; 18 | wallet: typeof blockchain.wallet; 19 | } 20 | >; 21 | 22 | before(async () => { 23 | const contractHash0 = await blockchain.wallet.deployContract({ 24 | abi: EMIT_ABI, 25 | bytecode: EMIT_BYTECODE, 26 | gas: 150000n, 27 | }); 28 | 29 | await blockchain.test.mine({ blocks: 1 }); 30 | 31 | const receipt0 = await blockchain.public.waitForTransactionReceipt({ 32 | hash: contractHash0, 33 | }); 34 | if (receipt0.status !== "success") { 35 | throw new Error(`not deployed: ${receipt0.status}`); 36 | } 37 | 38 | const address0 = receipt0.contractAddress; 39 | 40 | if (address0 == null) { 41 | throw "not deployed"; 42 | } 43 | 44 | contract0 = viem.getContract({ 45 | client: { 46 | public: blockchain.public, 47 | wallet: blockchain.wallet, 48 | }, 49 | address: address0, 50 | abi: EMIT_ABI, 51 | }); 52 | }); 53 | 54 | it("behaves when getting changes from an uninstalled filter", async () => { 55 | const filter = await wallet.public.createPendingTransactionFilter(); 56 | 57 | await wallet.public.uninstallFilter({ filter }); 58 | 59 | await assert.rejects(wallet.public.getFilterChanges({ filter })); 60 | }); 61 | 62 | it("behaves when getting changes from a filter that never existed", async () => { 63 | await assert.rejects( 64 | wallet.provider.request({ 65 | method: "eth_getFilterChanges", 66 | params: ["0xdeadc0de"], 67 | }), 68 | ); 69 | }); 70 | 71 | it("doesn't return events outside of block range", async () => { 72 | const filter = await wallet.public.createEventFilter({ 73 | fromBlock: 0x01ffffffffffffen, 74 | toBlock: 0x01fffffffffffffn, 75 | }); 76 | try { 77 | const call = await contract0.write.logSomething([1234n]); 78 | await blockchain.test.mine({ blocks: 1 }); 79 | await wallet.public.waitForTransactionReceipt({ hash: call }); 80 | const logs = await wallet.public.getFilterChanges({ filter }); 81 | assert.equal(logs.length, 0); 82 | } finally { 83 | await wallet.public.uninstallFilter({ filter }); 84 | } 85 | }); 86 | 87 | it("returns events inside the block range", async () => { 88 | const blockNumber = await wallet.public.getBlockNumber(); 89 | const filter = await wallet.public.createEventFilter({ 90 | fromBlock: blockNumber + 1n, 91 | toBlock: blockNumber + 2n, 92 | }); 93 | try { 94 | const call = await contract0.write.logSomething([1234n]); 95 | await blockchain.test.mine({ blocks: 1 }); 96 | const receipt = await wallet.public.waitForTransactionReceipt({ 97 | hash: call, 98 | }); 99 | const logs = await wallet.public.getFilterChanges({ filter }); 100 | assert.equal(logs.length, 1); 101 | assert.equal(logs[0].address, contract0.address); 102 | assert.equal(logs[0].blockHash, receipt.blockHash); 103 | assert.equal(logs[0].blockNumber, blockNumber + 1n); 104 | assert.equal( 105 | logs[0].topics[1], 106 | "0x00000000000000000000000000000000000000000000000000000000000004d2", 107 | ); 108 | assert.equal(logs[0].transactionHash, call); 109 | assert.equal(logs[0].transactionIndex, receipt.transactionIndex); 110 | } finally { 111 | await wallet.public.uninstallFilter({ filter }); 112 | } 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /client/src/tests/eth/getFilterLogs.ts: -------------------------------------------------------------------------------- 1 | import { EMIT_ABI, EMIT_BYTECODE } from "../../contracts/newFilter.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | import * as viem from "viem"; 5 | 6 | const blockchain = tests.blockchain; 7 | const wallet = tests.wallet; 8 | 9 | if (!blockchain || !wallet) { 10 | throw "not ready"; 11 | } 12 | 13 | describe("getFilterLogs", () => { 14 | let contract0: viem.GetContractReturnType< 15 | typeof EMIT_ABI, 16 | { 17 | public: typeof blockchain.public; 18 | wallet: typeof blockchain.wallet; 19 | } 20 | >; 21 | 22 | let contract1: viem.GetContractReturnType< 23 | typeof EMIT_ABI, 24 | { 25 | public: typeof blockchain.public; 26 | wallet: typeof blockchain.wallet; 27 | } 28 | >; 29 | 30 | before(async () => { 31 | const contractHash0 = await blockchain.wallet.deployContract({ 32 | abi: EMIT_ABI, 33 | bytecode: EMIT_BYTECODE, 34 | gas: 150000n, 35 | }); 36 | const contractHash1 = await blockchain.wallet.deployContract({ 37 | abi: EMIT_ABI, 38 | bytecode: EMIT_BYTECODE, 39 | gas: 150000n, 40 | }); 41 | 42 | await blockchain.test.mine({ blocks: 1 }); 43 | 44 | const receipt0 = await blockchain.public.waitForTransactionReceipt({ 45 | hash: contractHash0, 46 | }); 47 | const receipt1 = await blockchain.public.waitForTransactionReceipt({ 48 | hash: contractHash1, 49 | }); 50 | if (receipt0.status !== "success" || receipt1.status !== "success") { 51 | throw new Error( 52 | `not deployed: ${receipt0.status} and ${receipt1.status}`, 53 | ); 54 | } 55 | 56 | const address0 = receipt0.contractAddress; 57 | const address1 = receipt1.contractAddress; 58 | 59 | if (address0 == null || address1 == null) { 60 | throw "not deployed"; 61 | } 62 | 63 | contract0 = viem.getContract({ 64 | client: { 65 | public: blockchain.public, 66 | wallet: blockchain.wallet, 67 | }, 68 | address: address0, 69 | abi: EMIT_ABI, 70 | }); 71 | contract1 = viem.getContract({ 72 | client: { 73 | public: blockchain.public, 74 | wallet: blockchain.wallet, 75 | }, 76 | address: address1, 77 | abi: EMIT_ABI, 78 | }); 79 | }); 80 | 81 | it("returns events matching filter", async () => { 82 | const filter = await wallet.public.createEventFilter({ 83 | address: contract0.address, 84 | }); 85 | try { 86 | const call = await contract0.write.logSomething([1234n]); 87 | await blockchain.test.mine({ blocks: 1 }); 88 | await wallet.public.waitForTransactionReceipt({ hash: call }); 89 | const actual = await wallet.public.getFilterLogs({ filter }); 90 | assert.equal(actual[0].topics[1], 1234n); 91 | } finally { 92 | await wallet.public.uninstallFilter({ filter }); 93 | } 94 | }); 95 | 96 | it("doesn't return events from different contract", async () => { 97 | const filter = await wallet.public.createEventFilter({ 98 | address: contract0.address, 99 | }); 100 | try { 101 | const call = await contract1.write.logSomething([1234n]); 102 | await blockchain.test.mine({ blocks: 1 }); 103 | await wallet.public.waitForTransactionReceipt({ hash: call }); 104 | const actual = await wallet.public.getFilterLogs({ filter }); 105 | assert.equal(actual.length, 0); 106 | } finally { 107 | await wallet.public.uninstallFilter({ filter }); 108 | } 109 | }); 110 | 111 | it("doesn't return events outside of block range", async () => { 112 | const filter = await wallet.public.createEventFilter({ 113 | fromBlock: 114 | 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffen, 115 | toBlock: 116 | 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn, 117 | }); 118 | try { 119 | const call = await contract0.write.logSomething([1234n]); 120 | await blockchain.test.mine({ blocks: 1 }); 121 | await wallet.public.waitForTransactionReceipt({ hash: call }); 122 | const logs = await wallet.public.getFilterLogs({ filter }); 123 | assert.equal(logs.length, 0); 124 | } finally { 125 | await wallet.public.uninstallFilter({ filter }); 126 | } 127 | }); 128 | 129 | it("returns events inside the block range", async () => { 130 | const blockNumber = await wallet.public.getBlockNumber(); 131 | const filter = await wallet.public.createEventFilter({ 132 | fromBlock: blockNumber + 1n, 133 | toBlock: blockNumber + 2n, 134 | }); 135 | try { 136 | const call = await contract0.write.logSomething([1234n]); 137 | await blockchain.test.mine({ blocks: 1 }); 138 | const receipt = await wallet.public.waitForTransactionReceipt({ 139 | hash: call, 140 | }); 141 | const logs = await wallet.public.getFilterLogs({ filter }); 142 | assert.equal(logs.length, 1); 143 | assert.equal(logs[0].address, contract0.address); 144 | assert.equal(logs[0].blockHash, receipt.blockHash); 145 | assert.equal(logs[0].blockNumber, blockNumber + 1n); 146 | assert.equal( 147 | logs[0].topics[1], 148 | "0x00000000000000000000000000000000000000000000000000000000000004d2", 149 | ); 150 | assert.equal(logs[0].transactionHash, call); 151 | assert.equal(logs[0].transactionIndex, receipt.transactionIndex); 152 | } finally { 153 | await wallet.public.uninstallFilter({ filter }); 154 | } 155 | }); 156 | 157 | it("behaves when getting logs from new block filter", async () => { 158 | const filter: unknown = await wallet.provider.request({ 159 | method: "eth_newBlockFilter", 160 | }); 161 | if (typeof filter !== "string") { 162 | assert.fail("filter not a string"); 163 | } 164 | 165 | try { 166 | await assert.rejects( 167 | wallet.provider.request({ 168 | method: "eth_getFilterLogs", 169 | params: [filter], 170 | }), 171 | ); 172 | } finally { 173 | await wallet.provider.request({ 174 | method: "eth_uninstallFilter", 175 | params: [filter], 176 | }); 177 | } 178 | }); 179 | 180 | it("behaves when getting logs from new pending transaction filter", async () => { 181 | const filter: unknown = await wallet.provider.request({ 182 | method: "eth_newPendingTransactionFilter", 183 | }); 184 | if (typeof filter !== "string") { 185 | assert.fail("filter not a string"); 186 | } 187 | 188 | try { 189 | await assert.rejects( 190 | wallet.provider.request({ 191 | method: "eth_getFilterLogs", 192 | params: [filter], 193 | }), 194 | ); 195 | } finally { 196 | await wallet.provider.request({ 197 | method: "eth_uninstallFilter", 198 | params: [filter], 199 | }); 200 | } 201 | }); 202 | 203 | it("behaves when getting logs from an uninstalled filter", async () => { 204 | const filter = await wallet.public.createEventFilter(); 205 | 206 | await wallet.public.uninstallFilter({ filter }); 207 | 208 | await assert.rejects(wallet.public.getFilterLogs({ filter })); 209 | }); 210 | 211 | it("behaves when getting changes from a filter that never existed", async () => { 212 | await assert.rejects( 213 | wallet.provider.request({ 214 | method: "eth_getFilterLogs", 215 | params: ["0xdeadc0de"], 216 | }), 217 | ); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /client/src/tests/eth/getLogs.ts: -------------------------------------------------------------------------------- 1 | import { EMIT_ABI, EMIT_BYTECODE } from "../../contracts/newFilter.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | import * as viem from "viem"; 5 | 6 | const blockchain = tests.blockchain; 7 | const wallet = tests.wallet; 8 | 9 | if (!blockchain || !wallet) { 10 | throw "not ready"; 11 | } 12 | 13 | describe("getLogs", () => { 14 | let contractAddress0: `0x${string}`; 15 | let contractAddress1: `0x${string}`; 16 | let contract0: viem.GetContractReturnType< 17 | typeof EMIT_ABI, 18 | { 19 | public: typeof blockchain.public; 20 | wallet: typeof blockchain.wallet; 21 | } 22 | >; 23 | 24 | before(async () => { 25 | const contractHash0 = await blockchain.wallet.deployContract({ 26 | abi: EMIT_ABI, 27 | bytecode: EMIT_BYTECODE, 28 | gas: 150000n, 29 | }); 30 | const contractHash1 = await blockchain.wallet.deployContract({ 31 | abi: EMIT_ABI, 32 | bytecode: EMIT_BYTECODE, 33 | gas: 150000n, 34 | }); 35 | 36 | await blockchain.test.mine({ blocks: 1 }); 37 | 38 | const receipt0 = await blockchain.public.waitForTransactionReceipt({ 39 | hash: contractHash0, 40 | }); 41 | const receipt1 = await blockchain.public.waitForTransactionReceipt({ 42 | hash: contractHash1, 43 | }); 44 | if (receipt0.status !== "success" || receipt1.status !== "success") { 45 | throw new Error( 46 | `not deployed: ${receipt0.status} and ${receipt1.status}`, 47 | ); 48 | } 49 | 50 | const address0 = receipt0.contractAddress; 51 | const address1 = receipt1.contractAddress; 52 | 53 | if (address0 == null || address1 == null) { 54 | throw "not deployed"; 55 | } 56 | 57 | contractAddress0 = address0; 58 | contractAddress1 = address1; 59 | 60 | contract0 = viem.getContract({ 61 | client: { 62 | public: blockchain.public, 63 | wallet: blockchain.wallet, 64 | }, 65 | address: address0, 66 | abi: EMIT_ABI, 67 | }); 68 | }); 69 | 70 | it("doesn't return events outside of block range", async () => { 71 | const call = await contract0.write.logSomething([1234n]); 72 | await blockchain.test.mine({ blocks: 1 }); 73 | await wallet.public.waitForTransactionReceipt({ hash: call }); 74 | const logs = await wallet.public.getLogs({ 75 | fromBlock: 0x01ffffffffffffen, 76 | toBlock: 0x01fffffffffffffn, 77 | }); 78 | assert.equal(logs.length, 0); 79 | }); 80 | 81 | it("doesn't return events for a different contract", async () => { 82 | const blockNumber = await blockchain.public.getBlockNumber(); 83 | const call = await contract0.write.logSomething([1234n]); 84 | await blockchain.test.mine({ blocks: 1 }); 85 | await wallet.public.waitForTransactionReceipt({ hash: call }); 86 | const logs = await wallet.public.getLogs({ 87 | fromBlock: blockNumber + 1n, 88 | toBlock: blockNumber + 1n, 89 | address: contractAddress1, 90 | }); 91 | assert.equal(logs.length, 0); 92 | }); 93 | 94 | it("returns events from a recent block", async () => { 95 | const blockNumber = await blockchain.public.getBlockNumber(); 96 | const call = await contract0.write.logSomething([1234n]); 97 | await blockchain.test.mine({ blocks: 1 }); 98 | const receipt = await wallet.public.waitForTransactionReceipt({ 99 | hash: call, 100 | }); 101 | const logs = await wallet.public.getLogs({ 102 | fromBlock: blockNumber + 1n, 103 | toBlock: blockNumber + 1n, 104 | address: contractAddress0, 105 | }); 106 | assert.equal(logs.length, 1); 107 | assert.equal(logs[0].address, contract0.address); 108 | assert.equal(logs[0].blockHash, receipt.blockHash); 109 | assert.equal(logs[0].blockNumber, blockNumber + 1n); 110 | assert.equal( 111 | logs[0].topics[1], 112 | "0x00000000000000000000000000000000000000000000000000000000000004d2", 113 | ); 114 | assert.equal(logs[0].transactionHash, call); 115 | assert.equal(logs[0].transactionIndex, receipt.transactionIndex); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /client/src/tests/eth/getProof.ts: -------------------------------------------------------------------------------- 1 | import { STORE_ABI, STORE_BYTECODE } from "../../contracts/getStorageAt.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | import * as viem from "viem"; 5 | 6 | const blockchain = tests.blockchain; 7 | const wallet = tests.wallet; 8 | 9 | if (!blockchain || !wallet) { 10 | throw "not ready"; 11 | } 12 | 13 | function asProof(input: unknown): { 14 | codeHash: string; 15 | storageProof: Array<{ value: string }>; 16 | } { 17 | if (!input || typeof input !== "object") { 18 | throw "response not an object"; 19 | } 20 | 21 | if (!("codeHash" in input)) { 22 | throw "missing codehash"; 23 | } 24 | 25 | const codeHash = input.codeHash; 26 | 27 | if (!codeHash || typeof codeHash !== "string") { 28 | throw "bad codehash"; 29 | } 30 | 31 | if (!("storageProof" in input)) { 32 | throw "missing storage proof"; 33 | } 34 | 35 | const storageProof = input.storageProof; 36 | 37 | if (!Array.isArray(storageProof)) { 38 | throw "bad storage proof"; 39 | } 40 | 41 | for (const x of storageProof) { 42 | if (typeof x !== "object") { 43 | throw "bad proof"; 44 | } 45 | 46 | const obj: object = x as object; 47 | 48 | if (!("value" in obj) || typeof obj.value !== "string") { 49 | throw "bad proof"; 50 | } 51 | } 52 | 53 | return { 54 | ...input, 55 | codeHash, 56 | storageProof, 57 | }; 58 | } 59 | 60 | describe("getProof", () => { 61 | let contractAddress: `0x${string}`; 62 | let contract: viem.GetContractReturnType< 63 | typeof STORE_ABI, 64 | { 65 | public: typeof blockchain.public; 66 | wallet: typeof blockchain.wallet; 67 | } 68 | >; 69 | 70 | before(async () => { 71 | const contractHash = await blockchain.wallet.deployContract({ 72 | abi: STORE_ABI, 73 | bytecode: STORE_BYTECODE, 74 | gas: 150000n, 75 | }); 76 | 77 | await blockchain.test.mine({ blocks: 1 }); 78 | 79 | const address = ( 80 | await blockchain.public.waitForTransactionReceipt({ 81 | hash: contractHash, 82 | }) 83 | ).contractAddress; 84 | if (address == null) { 85 | throw "not deployed"; 86 | } 87 | contractAddress = address; 88 | contract = viem.getContract({ 89 | client: { 90 | public: blockchain.public, 91 | wallet: blockchain.wallet, 92 | }, 93 | address, 94 | abi: STORE_ABI, 95 | }); 96 | }); 97 | 98 | it.skip("returns a proof matching client for EOA", async () => { 99 | const blockNumber = await blockchain.public.getBlockNumber(); 100 | 101 | const fromBlockchain = asProof( 102 | await blockchain.provider.request({ 103 | method: "eth_getProof", 104 | params: [ 105 | "0x0000000000000000000000000000000000000000", 106 | ["0x00"], 107 | blockNumber.toString(16), 108 | ], 109 | }), 110 | ); 111 | 112 | const fromWallet = asProof( 113 | await wallet.provider.request({ 114 | method: "eth_getProof", 115 | params: [ 116 | "0x0000000000000000000000000000000000000000", 117 | ["0x00"], 118 | blockNumber.toString(16), 119 | ], 120 | }), 121 | ); 122 | 123 | assert.deepEqual(fromWallet, fromBlockchain); 124 | assert.equal( 125 | fromWallet.codeHash, 126 | "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", 127 | ); 128 | assert.equal(fromWallet.storageProof.length, 1); 129 | }); 130 | 131 | it("returns the correct value for a contract", async () => { 132 | const blockNumber = await blockchain.public.getBlockNumber(); 133 | 134 | const storage = await wallet.public.getStorageAt({ 135 | address: contractAddress, 136 | slot: "0x00", 137 | }); 138 | 139 | assert.equal(storage, "0x"); 140 | 141 | const response = await contract.write.store([0x89n]); 142 | 143 | await blockchain.test.mine({ blocks: 1 }); 144 | 145 | await wallet.public.waitForTransactionReceipt({ hash: response }); 146 | 147 | const nextBlockNumberHex = "0x" + (blockNumber + 1n).toString(16); 148 | const fromBlockchain = asProof( 149 | await blockchain.provider.request({ 150 | method: "eth_getProof", 151 | params: [contractAddress, ["0x00"], nextBlockNumberHex], 152 | }), 153 | ); 154 | 155 | const fromWallet = asProof( 156 | await wallet.provider.request({ 157 | method: "eth_getProof", 158 | params: [contractAddress, ["0x00"], nextBlockNumberHex], 159 | }), 160 | ); 161 | 162 | assert.deepEqual(fromWallet, fromBlockchain); 163 | assert.equal( 164 | fromWallet.codeHash, 165 | "0x64c4775c8291ba05add5689df55b0662da1e1608c70cf6ac1ab76be406ce9a0d", 166 | ); 167 | assert.equal(fromWallet.storageProof.length, 1); 168 | assert.equal(fromWallet.storageProof[0].value, "0x89"); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /client/src/tests/eth/getStorageAt.ts: -------------------------------------------------------------------------------- 1 | import { STORE_ABI, STORE_BYTECODE } from "../../contracts/getStorageAt.sol"; 2 | import * as tests from "../../tests"; 3 | import assert from "assert"; 4 | import * as viem from "viem"; 5 | 6 | const blockchain = tests.blockchain; 7 | const wallet = tests.wallet; 8 | 9 | if (!blockchain || !wallet) { 10 | throw "not ready"; 11 | } 12 | 13 | describe("getStorageAt", () => { 14 | let contractAddress: `0x${string}`; 15 | let contract: viem.GetContractReturnType< 16 | typeof STORE_ABI, 17 | { 18 | public: typeof blockchain.public; 19 | wallet: typeof blockchain.wallet; 20 | } 21 | >; 22 | 23 | before(async () => { 24 | const contractHash = await blockchain.wallet.deployContract({ 25 | abi: STORE_ABI, 26 | bytecode: STORE_BYTECODE, 27 | gas: 150000n, 28 | }); 29 | 30 | await blockchain.test.mine({ blocks: 1 }); 31 | 32 | const address = ( 33 | await blockchain.public.waitForTransactionReceipt({ 34 | hash: contractHash, 35 | }) 36 | ).contractAddress; 37 | if (address == null) { 38 | throw "not deployed"; 39 | } 40 | contractAddress = address; 41 | contract = viem.getContract({ 42 | client: { 43 | public: blockchain.public, 44 | wallet: blockchain.wallet, 45 | }, 46 | address, 47 | abi: STORE_ABI, 48 | }); 49 | }); 50 | 51 | it("returns zero for an empty account", async () => { 52 | const storage = await wallet.public.getStorageAt({ 53 | address: "0x0000000000000000000000000000000000000000", 54 | slot: "0x00", 55 | }); 56 | 57 | assert.equal(storage, "0x"); 58 | }); 59 | 60 | it("returns the correct value for a contract", async () => { 61 | const storage = await wallet.public.getStorageAt({ 62 | address: contractAddress, 63 | slot: "0x00", 64 | }); 65 | 66 | assert.equal(storage, "0x"); 67 | const response = await contract.write.store([0x89n]); 68 | 69 | await blockchain.test.mine({ blocks: 1 }); 70 | 71 | await wallet.public.waitForTransactionReceipt({ hash: response }); 72 | const storageAfter = await wallet.public.getStorageAt({ 73 | address: contractAddress, 74 | slot: "0x00", 75 | }); 76 | 77 | assert.equal( 78 | storageAfter, 79 | "0x0000000000000000000000000000000000000000000000000000000000000089", 80 | ); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /client/src/tests/eth/getTransactionByBlockHashAndIndex.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("getTransactionByBlockHashAndIndex", () => { 12 | it("returns the mined transactions", async () => { 13 | const response = await blockchain.wallet.sendTransaction({ 14 | to: "0x0000000000000000000000000000000000000000", 15 | value: 0n, 16 | }); 17 | 18 | await blockchain.test.mine({ blocks: 1 }); 19 | 20 | const receipt = await wallet.public.waitForTransactionReceipt({ 21 | hash: response, 22 | }); 23 | 24 | const tx: unknown = await wallet.provider.request({ 25 | method: "eth_getTransactionByBlockHashAndIndex", 26 | params: [receipt.blockHash, 0], 27 | }); 28 | if (!tx || typeof tx !== "object" || !("hash" in tx)) { 29 | assert.fail("Could not decode transaction"); 30 | } 31 | 32 | assert.equal(tx.hash, response); 33 | }); 34 | 35 | it("behaves when requesting non-existent block", async () => { 36 | const tx: unknown = await wallet.provider.request({ 37 | method: "eth_getTransactionByBlockHashAndIndex", 38 | params: [ 39 | "0x0000000000000000000000000000000000000000000000000000000000000000", 40 | 0, 41 | ], 42 | }); 43 | 44 | assert.strictEqual(tx, null); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /client/src/tests/eth/getTransactionByBlockNumberAndIndex.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("getTransactionByBlockNumberAndIndex", () => { 12 | it("returns the mined transactions", async () => { 13 | const response = await blockchain.wallet.sendTransaction({ 14 | to: "0x0000000000000000000000000000000000000000", 15 | value: 0n, 16 | }); 17 | 18 | await blockchain.test.mine({ blocks: 1 }); 19 | 20 | const receipt = await wallet.public.waitForTransactionReceipt({ 21 | hash: response, 22 | }); 23 | 24 | const tx: unknown = await wallet.provider.request({ 25 | method: "eth_getTransactionByBlockNumberAndIndex", 26 | params: ["0x" + receipt.blockNumber.toString(16), 0], 27 | }); 28 | if (!tx || typeof tx !== "object" || !("hash" in tx)) { 29 | assert.fail("Could not decode transaction"); 30 | } 31 | 32 | assert.equal(tx.hash, response); 33 | }); 34 | 35 | it("behaves when requesting non-existent block", async () => { 36 | const tx: unknown = await wallet.provider.request({ 37 | method: "eth_getTransactionByBlockNumberAndIndex", 38 | params: [ 39 | "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 40 | 0, 41 | ], 42 | }); 43 | 44 | assert.strictEqual(tx, null); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /client/src/tests/eth/getTransactionByHash.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("getTransctionByHash", () => { 12 | it("returns the same transaction as sent", async () => { 13 | const dest = wallet.wallet.account.address; 14 | 15 | const value = 0n; 16 | const response = await blockchain.wallet.sendTransaction({ 17 | to: dest, 18 | value: value, 19 | }); 20 | 21 | await blockchain.test.mine({ blocks: 5000 }); 22 | await blockchain.public.waitForTransactionReceipt({ hash: response }); 23 | 24 | const walletTransactionByHash = await wallet.public.getTransaction({ 25 | hash: response, 26 | }); 27 | 28 | assert.equal( 29 | walletTransactionByHash.from, 30 | blockchain.wallet.account.address, 31 | ); 32 | 33 | assert.equal(walletTransactionByHash.to, dest); 34 | 35 | assert.equal(walletTransactionByHash.value, value); 36 | }); 37 | 38 | it("behaves when given a non-existent transaction", async () => { 39 | await assert.rejects( 40 | wallet.public.getTransaction({ 41 | hash: "0x0000000000000000000000000000000000000000000000000000000000000000", 42 | }), 43 | ); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /client/src/tests/eth/getTransactionCount.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEPLOY_ABI, 3 | DEPLOY_BYTECODE, 4 | } from "../../contracts/getTransactionCount.sol"; 5 | import * as tests from "../../tests"; 6 | import assert from "assert"; 7 | import * as viem from "viem"; 8 | 9 | const blockchain = tests.blockchain; 10 | const wallet = tests.wallet; 11 | 12 | if (!blockchain || !wallet) { 13 | throw "not ready"; 14 | } 15 | 16 | describe("getTransactionCount", () => { 17 | let contractAddress: `0x${string}`; 18 | let contract: viem.GetContractReturnType< 19 | typeof DEPLOY_ABI, 20 | { 21 | public: typeof blockchain.public; 22 | wallet: typeof blockchain.wallet; 23 | } 24 | >; 25 | 26 | before(async () => { 27 | const contractHash = await blockchain.wallet.deployContract({ 28 | abi: DEPLOY_ABI, 29 | gas: 130000n, 30 | bytecode: DEPLOY_BYTECODE, 31 | }); 32 | 33 | await blockchain.test.mine({ blocks: 1 }); 34 | 35 | const address = ( 36 | await blockchain.public.waitForTransactionReceipt({ 37 | hash: contractHash, 38 | }) 39 | ).contractAddress; 40 | if (address == null) { 41 | throw "not deployed"; 42 | } 43 | contractAddress = address; 44 | contract = viem.getContract({ 45 | client: { 46 | public: blockchain.public, 47 | wallet: blockchain.wallet, 48 | }, 49 | address, 50 | abi: DEPLOY_ABI, 51 | }); 52 | }); 53 | 54 | it("sending transaction from eoa increases nonce", async () => { 55 | const src = blockchain.wallet.account; 56 | const dest = wallet.wallet.account; 57 | 58 | const balance = 0x100000000000000000000n; 59 | await blockchain.test.setBalance({ 60 | address: src.address, 61 | value: balance, 62 | }); 63 | 64 | const walletInitalNonce = await wallet.public.getTransactionCount({ 65 | address: src.address, 66 | }); 67 | const ganacheInitalNonce = await blockchain.public.getTransactionCount({ 68 | address: src.address, 69 | }); 70 | 71 | assert.equal( 72 | walletInitalNonce, 73 | ganacheInitalNonce, 74 | "wallet's nonce matches ganache's before sending transaction", 75 | ); 76 | 77 | const response = await blockchain.wallet.sendTransaction({ 78 | to: dest.address, 79 | value: 0n, 80 | }); 81 | 82 | await blockchain.test.mine({ blocks: 1 }); 83 | 84 | await wallet.public.waitForTransactionReceipt({ hash: response }); 85 | 86 | const walletFinalNonce = await wallet.public.getTransactionCount({ 87 | address: src.address, 88 | }); 89 | const ganacheFinalNonce = await blockchain.public.getTransactionCount({ 90 | address: src.address, 91 | }); 92 | 93 | assert.equal( 94 | walletFinalNonce, 95 | ganacheFinalNonce, 96 | "wallet's nonce matches ganache's after sending transaction", 97 | ); 98 | 99 | const expected = 1 + walletInitalNonce; 100 | 101 | assert.equal(walletFinalNonce, expected); 102 | }); 103 | 104 | it("deploying from contract increases nonce", async () => { 105 | const src = blockchain.wallet.account; 106 | 107 | const balance = 0x100000000000000000000n; 108 | await blockchain.test.setBalance({ 109 | address: src.address, 110 | value: balance, 111 | }); 112 | 113 | const walletInitalNonce = await wallet.public.getTransactionCount({ 114 | address: contractAddress, 115 | }); 116 | const ganacheInitalNonce = await blockchain.public.getTransactionCount({ 117 | address: contractAddress, 118 | }); 119 | 120 | assert.equal( 121 | walletInitalNonce, 122 | ganacheInitalNonce, 123 | "wallet's nonce matches ganache's before sending transaction", 124 | ); 125 | 126 | const response = await contract.write.deploy(); 127 | 128 | await blockchain.test.mine({ blocks: 1 }); 129 | 130 | await wallet.public.waitForTransactionReceipt({ hash: response }); 131 | 132 | const walletFinalNonce = await wallet.public.getTransactionCount({ 133 | address: contractAddress, 134 | }); 135 | const ganacheFinalNonce = await blockchain.public.getTransactionCount({ 136 | address: contractAddress, 137 | }); 138 | 139 | assert.equal( 140 | walletFinalNonce, 141 | ganacheFinalNonce, 142 | "wallet's nonce matches ganache's after sending transaction", 143 | ); 144 | 145 | const expected = 1 + walletInitalNonce; 146 | 147 | assert.equal(walletFinalNonce, expected); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /client/src/tests/eth/getTransactionReceipt.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("getTransactionReceipt", () => { 12 | it("returns receipt matching transaction", async () => { 13 | const response = await blockchain.wallet.sendTransaction({ 14 | to: "0x0000000000000000001000000000000000000000", 15 | value: 16n, 16 | }); 17 | 18 | const blockNumber = 1n + (await blockchain.public.getBlockNumber()); 19 | 20 | await blockchain.test.mine({ blocks: 1 }); 21 | 22 | const block = await blockchain.public.getBlock({ blockNumber }); 23 | 24 | const receipt = await wallet.public.waitForTransactionReceipt({ 25 | hash: response, 26 | }); 27 | 28 | assert.equal(receipt.blockHash, block.hash); 29 | assert.equal(receipt.blockNumber, block.number); 30 | assert.equal(receipt.contractAddress, null); 31 | assert.equal(receipt.cumulativeGasUsed, block.gasUsed); 32 | assert.equal(receipt.from, blockchain.wallet.account.address); 33 | assert.equal(receipt.gasUsed, block.gasUsed); 34 | assert.equal(receipt.logs.length, 0); 35 | assert.equal(receipt.status, "success"); 36 | assert.equal(receipt.to, "0x0000000000000000001000000000000000000000"); 37 | assert.equal(receipt.transactionHash, response); 38 | assert.equal(receipt.transactionIndex, 0); 39 | }); 40 | 41 | it("behaves when fetching non-existent transaction", async () => { 42 | await assert.rejects( 43 | wallet.public.getTransactionReceipt({ 44 | hash: "0x0000000000000000000000000000000000000000000000000000000000000000", 45 | }), 46 | ); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /client/src/tests/eth/maxPriorityFeePerGas.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("maxPriorityFeePerGas", () => { 12 | it("returns the same max priority fee as the client", async () => { 13 | await blockchain.test.mine({ blocks: 2 }); 14 | 15 | const fromWallet: unknown = await wallet.provider.request({ 16 | method: "eth_maxPriorityFeePerGas", 17 | }); 18 | const fromGanache: unknown = await blockchain.provider.request({ 19 | method: "eth_maxPriorityFeePerGas", 20 | }); 21 | 22 | assert.deepEqual(fromWallet, fromGanache); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/tests/eth/newBlockFilter.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("newBlockFilter", () => { 12 | it("returns newly mined blocks", async () => { 13 | const filter = await wallet.public.createBlockFilter(); 14 | 15 | const blockNumber = await blockchain.public.getBlockNumber(); 16 | await blockchain.test.mine({ blocks: 1 }); 17 | const blockHash = ( 18 | await blockchain.public.getBlock({ blockNumber: blockNumber + 1n }) 19 | ).hash; 20 | 21 | const hashes = await wallet.public.getFilterChanges({ filter }); 22 | 23 | await wallet.public.uninstallFilter({ filter }); 24 | assert.deepEqual(hashes, [blockHash]); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /client/src/tests/eth/newFilter.ts: -------------------------------------------------------------------------------- 1 | import { EMIT_ABI, EMIT_BYTECODE } from "../../contracts/newFilter.sol"; 2 | import * as tests from "../../tests"; 3 | import { notEver } from "../../util"; 4 | import assert from "assert"; 5 | import * as viem from "viem"; 6 | 7 | const blockchain = tests.blockchain; 8 | const wallet = tests.wallet; 9 | 10 | if (!blockchain || !wallet) { 11 | throw "not ready"; 12 | } 13 | 14 | describe("newFilter", () => { 15 | let contract0: viem.GetContractReturnType< 16 | typeof EMIT_ABI, 17 | { 18 | public: typeof blockchain.public; 19 | wallet: typeof blockchain.wallet; 20 | } 21 | >; 22 | 23 | let contract1: viem.GetContractReturnType< 24 | typeof EMIT_ABI, 25 | { 26 | public: typeof blockchain.public; 27 | wallet: typeof blockchain.wallet; 28 | } 29 | >; 30 | 31 | before(async () => { 32 | const contractHash0 = await blockchain.wallet.deployContract({ 33 | abi: EMIT_ABI, 34 | bytecode: EMIT_BYTECODE, 35 | gas: 150000n, 36 | }); 37 | const contractHash1 = await blockchain.wallet.deployContract({ 38 | abi: EMIT_ABI, 39 | bytecode: EMIT_BYTECODE, 40 | gas: 150000n, 41 | }); 42 | 43 | await blockchain.test.mine({ blocks: 1 }); 44 | 45 | const receipt0 = await blockchain.public.waitForTransactionReceipt({ 46 | hash: contractHash0, 47 | }); 48 | const receipt1 = await blockchain.public.waitForTransactionReceipt({ 49 | hash: contractHash1, 50 | }); 51 | if (receipt0.status !== "success" || receipt1.status !== "success") { 52 | throw new Error( 53 | `not deployed: ${receipt0.status} and ${receipt1.status}`, 54 | ); 55 | } 56 | 57 | const address0 = receipt0.contractAddress; 58 | const address1 = receipt1.contractAddress; 59 | 60 | if (address0 == null || address1 == null) { 61 | throw "not deployed"; 62 | } 63 | 64 | contract0 = viem.getContract({ 65 | client: { 66 | public: blockchain.public, 67 | wallet: blockchain.wallet, 68 | }, 69 | address: address0, 70 | abi: EMIT_ABI, 71 | }); 72 | contract1 = viem.getContract({ 73 | client: { 74 | public: blockchain.public, 75 | wallet: blockchain.wallet, 76 | }, 77 | address: address1, 78 | abi: EMIT_ABI, 79 | }); 80 | }); 81 | 82 | it("returns events matching filter", async () => { 83 | const eventPromise = new Promise((resolve) => { 84 | const unwatch = contract0.watchEvent.Log( 85 | {}, 86 | { 87 | pollingInterval: 100, 88 | onLogs: (a) => { 89 | unwatch(); 90 | resolve(a); 91 | }, 92 | }, 93 | ); 94 | }); 95 | const call = await contract0.write.logSomething([1234n]); 96 | await blockchain.test.mine({ blocks: 1 }); 97 | await wallet.public.waitForTransactionReceipt({ hash: call }); 98 | const actual = await eventPromise; 99 | assert.equal(actual[0].topics[1], 1234n); 100 | }); 101 | 102 | it("doesn't return events from different contract", async () => { 103 | const eventPromise = new Promise((resolve) => { 104 | const unwatch = contract1.watchEvent.Log( 105 | {}, 106 | { 107 | pollingInterval: 100, 108 | onLogs: (a) => { 109 | unwatch(); 110 | resolve(a); 111 | }, 112 | }, 113 | ); 114 | }); 115 | const call = await contract0.write.logSomething([1234n]); 116 | await blockchain.test.mine({ blocks: 1 }); 117 | await wallet.public.waitForTransactionReceipt({ hash: call }); 118 | await notEver(eventPromise); 119 | }); 120 | 121 | it("doesn't return events with different topic", async () => { 122 | const eventPromise = new Promise((resolve) => { 123 | const unwatch = contract0.watchEvent.Log( 124 | {}, 125 | { 126 | pollingInterval: 100, 127 | onLogs: (a) => { 128 | unwatch(); 129 | resolve(a); 130 | }, 131 | }, 132 | ); 133 | }); 134 | const call = await contract0.write.logSomethingElse([1234n]); 135 | await blockchain.test.mine({ blocks: 1 }); 136 | await wallet.public.waitForTransactionReceipt({ hash: call }); 137 | await notEver(eventPromise); 138 | }); 139 | 140 | // Skipped because ambiguous: ethereum/execution-apis#288 141 | xit("behaves when given invalid block range", async () => { 142 | await assert.rejects( 143 | wallet.provider.request({ 144 | method: "eth_newFilter", 145 | params: [{ fromBlock: "concrete", toBlock: "wood" }], 146 | }), 147 | ); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /client/src/tests/eth/newPendingTransactionFilter.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("newPendingTransactionFilter", () => { 12 | it("returns pending transactions", async () => { 13 | const filter = await wallet.public.createPendingTransactionFilter(); 14 | 15 | try { 16 | const tx = await blockchain.wallet.sendTransaction({}); 17 | 18 | const changes = await wallet.public.getFilterChanges({ filter }); 19 | 20 | assert.deepEqual(changes, [tx]); 21 | } finally { 22 | await blockchain.test.mine({ blocks: 1 }); 23 | 24 | await wallet.public.uninstallFilter({ filter }); 25 | } 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /client/src/tests/eth/sendRawTransaction.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import { retry } from "../../util"; 3 | import assert from "assert"; 4 | 5 | const blockchain = tests.blockchain; 6 | const wallet = tests.wallet; 7 | 8 | if (!blockchain || !wallet) { 9 | throw "not ready"; 10 | } 11 | 12 | describe("sendRawTransaction", () => { 13 | it("sends a raw transaction correctly", async () => { 14 | const sender = (await blockchain.wallet.getAddresses())[0]; 15 | 16 | const balance = 0x100000000000000000000n; 17 | await blockchain.test.setBalance({ address: sender, value: balance }); 18 | 19 | await blockchain.test.mine({ blocks: 1 }); 20 | 21 | await retry(async () => { 22 | const actual = await wallet.public.getBalance({ address: sender }); 23 | 24 | assert.equal(actual, balance); 25 | }); 26 | 27 | const value = 1100000000000000000n; 28 | const rawTransaction: unknown = await blockchain.provider.request({ 29 | method: "eth_signTransaction", 30 | params: [ 31 | { 32 | from: sender, 33 | to: sender, 34 | gas: "0xEA60", 35 | gasPrice: "0x77359400", 36 | value: "0x" + value.toString(16), 37 | }, 38 | ], 39 | }); 40 | 41 | const response: unknown = await wallet.provider.request({ 42 | method: "eth_sendRawTransaction", 43 | params: [rawTransaction], 44 | }); 45 | 46 | if ( 47 | !response || 48 | typeof response !== "string" || 49 | !response.startsWith("0x") 50 | ) { 51 | throw "invalid response"; 52 | } 53 | 54 | await blockchain.test.mine({ blocks: 1 }); 55 | const receipt = await wallet.public.waitForTransactionReceipt({ 56 | hash: response as `0x${string}`, 57 | }); 58 | 59 | assert.equal(receipt.status, "success"); 60 | assert.equal(receipt.from?.toUpperCase(), sender.toUpperCase()); 61 | assert.equal(receipt.to?.toUpperCase(), sender.toUpperCase()); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /client/src/tests/eth/sendTransaction.ts: -------------------------------------------------------------------------------- 1 | import { EMIT_ABI, EMIT_BYTECODE } from "../../contracts/newFilter.sol"; 2 | import * as tests from "../../tests"; 3 | import { retry } from "../../util"; 4 | import assert from "assert"; 5 | import * as viem from "viem"; 6 | 7 | const blockchain = tests.blockchain; 8 | const wallet = tests.wallet; 9 | 10 | if (!blockchain || !wallet) { 11 | throw "not ready"; 12 | } 13 | 14 | describe("sendTransaction", () => { 15 | let contractAddress: `0x${string}`; 16 | let contract: viem.GetContractReturnType< 17 | typeof EMIT_ABI, 18 | { 19 | public: typeof wallet.public; 20 | wallet: typeof wallet.wallet; 21 | } 22 | >; 23 | 24 | before(async () => { 25 | const contractHash = await blockchain.wallet.deployContract({ 26 | abi: EMIT_ABI, 27 | bytecode: EMIT_BYTECODE, 28 | }); 29 | 30 | await blockchain.test.mine({ blocks: 1 }); 31 | 32 | const address = ( 33 | await blockchain.public.waitForTransactionReceipt({ 34 | hash: contractHash, 35 | }) 36 | ).contractAddress; 37 | if (address == null) { 38 | throw "not deployed"; 39 | } 40 | contractAddress = address; 41 | contract = viem.getContract({ 42 | client: { 43 | public: wallet.public, 44 | wallet: wallet.wallet, 45 | }, 46 | address, 47 | abi: EMIT_ABI, 48 | }); 49 | }); 50 | 51 | it("sends a transaction correctly", async () => { 52 | const sender = (await wallet.wallet.getAddresses())[0]; 53 | 54 | const balance = 0x100000000000000000000n; 55 | await blockchain.test.setBalance({ address: sender, value: balance }); 56 | 57 | await blockchain.test.mine({ blocks: 1 }); 58 | 59 | await retry(async () => { 60 | const actual = await wallet.public.getBalance({ address: sender }); 61 | 62 | assert.equal(actual, balance); 63 | }); 64 | 65 | const value = 1100000000000000000n; 66 | const responsePromise = wallet.wallet.sendTransaction({ 67 | account: sender, 68 | to: sender, 69 | value: value, 70 | }); 71 | 72 | const sendEvent = await wallet.glue.next("sendtransaction"); 73 | 74 | assert.equal( 75 | sendEvent.from.toUpperCase(), 76 | sender.toUpperCase(), 77 | `event's from (${sendEvent.from}) matches request (${sender})`, 78 | ); 79 | assert.equal( 80 | sendEvent.to.toUpperCase(), 81 | sender.toUpperCase(), 82 | `event's to (${sendEvent.to}) matches request (${sender})`, 83 | ); 84 | assert.equal( 85 | BigInt(sendEvent.value), 86 | value, 87 | `event's value (${sendEvent.value}) matches request (${value})`, 88 | ); 89 | 90 | await wallet.glue.sendTransaction({ 91 | id: sendEvent.id, 92 | action: "approve", 93 | }); 94 | 95 | const response = await responsePromise; 96 | 97 | await blockchain.test.mine({ blocks: 1 }); 98 | const receipt = await wallet.public.waitForTransactionReceipt({ 99 | hash: response, 100 | }); 101 | 102 | assert.equal(receipt.status, "success"); 103 | assert.equal(receipt.from?.toUpperCase(), sender.toUpperCase()); 104 | assert.equal(receipt.to?.toUpperCase(), sender.toUpperCase()); 105 | }); 106 | 107 | it("sends a transaction to a contract", async () => { 108 | const sender = (await wallet.wallet.getAddresses())[0]; 109 | 110 | const balance = 0x100000000000000000000n; 111 | await blockchain.test.setBalance({ address: sender, value: balance }); 112 | 113 | await blockchain.test.mine({ blocks: 1 }); 114 | 115 | await retry(async () => { 116 | const actual = await wallet.public.getBalance({ address: sender }); 117 | 118 | assert.equal(actual, balance); 119 | }); 120 | 121 | const callPromise = contract.write.logSomething([1234n]); 122 | const sendEvent = await wallet.glue.next("sendtransaction"); 123 | 124 | assert.ok( 125 | viem.isAddressEqual(viem.getAddress(sendEvent.from), sender), 126 | `event's from (${sendEvent.from}) matches request (${sender})`, 127 | ); 128 | 129 | assert.ok( 130 | viem.isAddressEqual(viem.getAddress(sendEvent.to), contractAddress), 131 | `event's to (${sendEvent.to}) matches request (${contractAddress})`, 132 | ); 133 | 134 | assert.equal( 135 | sendEvent.data.toLowerCase(), 136 | "0xa2bed3f200000000000000000000000000000000000000000000000000000000000004d2", 137 | `event's data matches request`, 138 | ); 139 | 140 | await wallet.glue.sendTransaction({ 141 | id: sendEvent.id, 142 | action: "approve", 143 | }); 144 | 145 | const response = await callPromise; 146 | 147 | await blockchain.test.mine({ blocks: 1 }); 148 | const receipt = await wallet.public.waitForTransactionReceipt({ 149 | hash: response, 150 | }); 151 | 152 | assert.equal(receipt.status, "success"); 153 | assert.equal(receipt.from?.toUpperCase(), sender.toUpperCase()); 154 | assert.equal(receipt.to?.toUpperCase(), contractAddress.toUpperCase()); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /client/src/tests/eth/sign.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("sign", () => { 12 | it("returns a correctly signed message", async () => { 13 | const signaturePromise = wallet.wallet.signMessage({ message: "text" }); 14 | const signatureEvent = await wallet.glue.next("signmessage"); 15 | assert.equal(signatureEvent.message, "text"); 16 | 17 | await wallet.glue.signMessage({ 18 | id: signatureEvent.id, 19 | action: "approve", 20 | }); 21 | 22 | const signature = await signaturePromise; 23 | const valid = await blockchain.public.verifyMessage({ 24 | address: wallet.wallet.account.address, 25 | message: "text", 26 | signature, 27 | }); 28 | 29 | assert.ok( 30 | valid, 31 | `valid signature from ${wallet.wallet.account.address}`, 32 | ); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /client/src/tests/eth/signTransaction.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import { delay, notEver } from "../../util"; 3 | import { retry } from "../../util"; 4 | import assert from "assert"; 5 | import { parseTransaction } from "viem"; 6 | 7 | const blockchain = tests.blockchain; 8 | const wallet = tests.wallet; 9 | 10 | if (!blockchain || !wallet) { 11 | throw "not ready"; 12 | } 13 | 14 | describe("signTransaction", () => { 15 | it("signs a transaction correctly", async () => { 16 | const filter = await blockchain.public.createPendingTransactionFilter(); 17 | 18 | try { 19 | const sender = (await wallet.wallet.getAddresses())[0]; 20 | 21 | const balance = 0x100000000000000000000n; 22 | await blockchain.test.setBalance({ 23 | address: sender, 24 | value: balance, 25 | }); 26 | 27 | await blockchain.test.mine({ blocks: 1 }); 28 | 29 | await retry(async () => { 30 | const actual = await wallet.public.getBalance({ 31 | address: sender, 32 | }); 33 | 34 | assert.equal(actual, balance); 35 | }); 36 | 37 | const value = 1100000000000000000n; 38 | const responsePromise = wallet.provider.request({ 39 | method: "eth_signTransaction", 40 | params: [ 41 | { 42 | from: sender, 43 | to: sender, 44 | value: "0x" + value.toString(16), 45 | }, 46 | ], 47 | }); 48 | 49 | const signEvent = await wallet.glue.next("signtransaction"); 50 | 51 | assert.equal( 52 | signEvent.from.toUpperCase(), 53 | sender.toUpperCase(), 54 | `event's from (${signEvent.from}) matches request (${sender})`, 55 | ); 56 | assert.equal( 57 | signEvent.to.toUpperCase(), 58 | sender.toUpperCase(), 59 | `event's to (${signEvent.to}) matches request (${sender})`, 60 | ); 61 | assert.equal( 62 | BigInt(signEvent.value), 63 | value, 64 | `event's value (${signEvent.value}) matches request (${value})`, 65 | ); 66 | 67 | await wallet.glue.signTransaction({ 68 | id: signEvent.id, 69 | action: "approve", 70 | }); 71 | 72 | const response: unknown = await responsePromise; 73 | if (typeof response !== "string" || !response.startsWith("0x")) { 74 | throw "expected hex string"; 75 | } 76 | 77 | // Make sure the signed transaction doesn't appear on-chain. 78 | await notEver( 79 | (async () => { 80 | let changes = []; 81 | while (changes.length == 0) { 82 | await delay(100); 83 | changes = await blockchain.public.getFilterChanges({ 84 | filter, 85 | }); 86 | } 87 | })(), 88 | ); 89 | 90 | const transaction = parseTransaction(response as `0x${string}`); 91 | 92 | assert.equal(transaction.chainId, blockchain.public.chain.id); 93 | assert.equal(transaction.value, value); 94 | assert.equal(transaction.to?.toUpperCase(), sender.toUpperCase()); 95 | 96 | const rawHash: unknown = await blockchain.provider.request({ 97 | method: "eth_sendRawTransaction", 98 | params: [response], 99 | }); 100 | 101 | if (typeof rawHash !== "string" || !rawHash.startsWith("0x")) { 102 | throw "expected hex string"; 103 | } 104 | 105 | await blockchain.test.mine({ blocks: 1 }); 106 | 107 | const receipt = await blockchain.public.waitForTransactionReceipt({ 108 | hash: rawHash as `0x${string}`, 109 | }); 110 | 111 | assert.equal(receipt.status, "success"); 112 | } finally { 113 | await blockchain.public.uninstallFilter({ filter }); 114 | } 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /client/src/tests/eth/uninstallFilter.ts: -------------------------------------------------------------------------------- 1 | import * as tests from "../../tests"; 2 | import assert from "assert"; 3 | 4 | const blockchain = tests.blockchain; 5 | const wallet = tests.wallet; 6 | 7 | if (!blockchain || !wallet) { 8 | throw "not ready"; 9 | } 10 | 11 | describe("uninstallFilter", () => { 12 | it("behaves when removing a filter twice", async () => { 13 | const filter = await wallet.public.createPendingTransactionFilter(); 14 | 15 | const first = await wallet.public.uninstallFilter({ filter }); 16 | const second = await wallet.public.uninstallFilter({ filter }); 17 | 18 | assert.ok(first, "first uninstall succeeded"); 19 | assert.ok(!second, "second uninstall did not succeed"); 20 | }); 21 | 22 | it("behaves when removing a filter that never existed", async () => { 23 | const result: unknown = await wallet.provider.request({ 24 | method: "eth_uninstallFilter", 25 | params: ["0xdeadc0de"], 26 | }); 27 | assert.strictEqual(result, false, "uninstall failed successfully"); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /client/src/util.ts: -------------------------------------------------------------------------------- 1 | export function delay(ms: number): Promise { 2 | return new Promise((res) => setTimeout(res, ms)); 3 | } 4 | 5 | export interface RetryOptions { 6 | operation: F; 7 | 8 | /** 9 | * Maximum time to retry the operation. 10 | */ 11 | totalMillis?: number; 12 | 13 | /** 14 | * Time to wait after each attempt. 15 | */ 16 | delayMillis?: number; 17 | } 18 | 19 | export class Timeout extends Error {} 20 | 21 | export async function retry Promise, R>( 22 | func: F | RetryOptions, 23 | ...args: Parameters 24 | ): Promise { 25 | let options: RetryOptions; 26 | 27 | if (typeof func === "function") { 28 | options = { operation: func }; 29 | } else { 30 | options = func; 31 | } 32 | 33 | const { totalMillis = 30000, delayMillis = 1000 } = options; 34 | 35 | const deadline = Date.now() + totalMillis; 36 | 37 | const timeout = (async () => { 38 | // Give some extra time for the runner, so it has time to throw a more 39 | // useful error. 40 | await delay(totalMillis * 1.05); 41 | throw new Timeout(); 42 | })(); 43 | 44 | let lastCaught: null | { caught: unknown } = null; 45 | const runner = async () => { 46 | while (Date.now() < deadline) { 47 | try { 48 | return await options.operation(...args); 49 | } catch (caught: unknown) { 50 | lastCaught = { caught }; 51 | } 52 | 53 | const wait = Math.min(deadline - Date.now(), delayMillis); 54 | if (wait > 0) { 55 | await delay(wait); 56 | } 57 | } 58 | 59 | if (lastCaught === null) { 60 | throw new Timeout(); 61 | } else { 62 | throw lastCaught.caught; 63 | } 64 | }; 65 | 66 | return await Promise.race([timeout, runner()]); 67 | } 68 | 69 | export function notEver(promise: Promise): Promise { 70 | const throwingPromise = promise.then(() => { 71 | throw new Error("Promise resolved, but it shouldn't have"); 72 | }); 73 | 74 | const delayPromise = delay(30 * 1000); 75 | 76 | return Promise.race([throwingPromise, delayPromise]); 77 | } 78 | 79 | export function spawn, U>( 80 | f: (...args: T) => Promise, 81 | ): (...args: Parameters) => void { 82 | // Create an error here so we can display a backtrace at runtime if 83 | // something gets thrown (at least in browsers that support backtraces.) 84 | const origin = new Error("spawned here"); 85 | 86 | return (...args: Parameters): void => { 87 | f(...args).catch((error) => { 88 | console.error(error, "\n", origin); 89 | alert( 90 | "an unhandled exception has occurred; please check the console", 91 | ); 92 | }); 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /client/src/worker_chain.ts: -------------------------------------------------------------------------------- 1 | import Ganache, { EthereumProvider } from "ganache"; 2 | 3 | function onConfigure(evt: MessageEvent): void { 4 | removeEventListener("message", onConfigure); 5 | 6 | // Without doing runtime type checking, it's impossible to ensure the 7 | // message data conforms to the options type from ganache. 8 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 9 | const ganache = Ganache.provider(evt.data); 10 | addEventListener("message", (evt) => onMessage(ganache, evt)); 11 | } 12 | 13 | function onMessage(ganache: EthereumProvider, evt: MessageEvent): void { 14 | const reply = evt.ports[0]; 15 | ganache 16 | // Without doing runtime type checking, it's impossible to ensure the 17 | // message data conforms to the JSON RPC spec. 18 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 19 | .request(evt.data) 20 | .then((result) => reply.postMessage({ result })) 21 | .catch((error) => { 22 | console.error("Uncaught (in ganache worker thread)", error); 23 | reply.postMessage({ 24 | error: { 25 | message: String(error), 26 | code: -32000, 27 | }, 28 | }); 29 | }); 30 | } 31 | 32 | addEventListener("message", onConfigure); 33 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationDir": "../dist/client/" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/LICENSE.md: -------------------------------------------------------------------------------- 1 | --- 2 | weight: 500000000 3 | --- 4 | 5 | # License 6 | 7 | ## Creative Commons Attribution-ShareAlike 4.0 International 8 | 9 | Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. 10 | 11 | **Using Creative Commons Public Licenses** 12 | 13 | Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. 14 | 15 | - **Considerations for licensors:** Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors). 16 | 17 | - **Considerations for the public:** By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees). 18 | 19 | ### Creative Commons Attribution-ShareAlike 4.0 International Public License 20 | 21 | By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. 22 | 23 | #### Section 1 – Definitions. 24 | 25 | a. **Adapted Material** means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. 26 | 27 | b. **Adapter's License** means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. 28 | 29 | c. **BY-SA Compatible License** means a license listed at [creativecommons.org/compatiblelicenses](http://creativecommons.org/compatiblelicenses), approved by Creative Commons as essentially the equivalent of this Public License. 30 | 31 | d. **Copyright and Similar Rights** means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. 32 | 33 | e. **Effective Technological Measures** means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. 34 | 35 | f. **Exceptions and Limitations** means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. 36 | 37 | g. **License Elements** means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike. 38 | 39 | h. **Licensed Material** means the artistic or literary work, database, or other material to which the Licensor applied this Public License. 40 | 41 | i. **Licensed Rights** means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. 42 | 43 | j. **Licensor** means the individual(s) or entity(ies) granting rights under this Public License. 44 | 45 | k. **Share** means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. 46 | 47 | l. **Sui Generis Database Rights** means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. 48 | 49 | m. **You** means the individual or entity exercising the Licensed Rights under this Public License. **Your** has a corresponding meaning. 50 | 51 | #### Section 2 – Scope. 52 | 53 | a. **_License grant._** 54 | 55 | 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: 56 | 57 | A. reproduce and Share the Licensed Material, in whole or in part; and 58 | 59 | B. produce, reproduce, and Share Adapted Material. 60 | 61 | 2. **Exceptions and Limitations.** For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. 62 | 63 | 3. **Term.** The term of this Public License is specified in Section 6(a). 64 | 65 | 4. **Media and formats; technical modifications allowed.** The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. 66 | 67 | 5. **Downstream recipients.** 68 | 69 | A. **Offer from the Licensor – Licensed Material.** Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. 70 | 71 | B. **Additional offer from the Licensor – Adapted Material.** Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply. 72 | 73 | C. **No downstream restrictions.** You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. 74 | 75 | 6. **No endorsement.** Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). 76 | 77 | b. **_Other rights._** 78 | 79 | 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. 80 | 81 | 2. Patent and trademark rights are not licensed under this Public License. 82 | 83 | 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. 84 | 85 | #### Section 3 – License Conditions. 86 | 87 | Your exercise of the Licensed Rights is expressly made subject to the following conditions. 88 | 89 | a. **_Attribution._** 90 | 91 | 1. If You Share the Licensed Material (including in modified form), You must: 92 | 93 | A. retain the following if it is supplied by the Licensor with the Licensed Material: 94 | 95 | i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); 96 | 97 | ii. a copyright notice; 98 | 99 | iii. a notice that refers to this Public License; 100 | 101 | iv. a notice that refers to the disclaimer of warranties; 102 | 103 | v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; 104 | 105 | B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and 106 | 107 | C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. 108 | 109 | 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. 110 | 111 | 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 112 | 113 | b. **_ShareAlike._** 114 | 115 | In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply. 116 | 117 | 1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License. 118 | 119 | 2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material. 120 | 121 | 3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply. 122 | 123 | #### Section 4 – Sui Generis Database Rights. 124 | 125 | Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: 126 | 127 | a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; 128 | 129 | b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and 130 | 131 | c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. 132 | 133 | For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. 134 | 135 | #### Section 5 – Disclaimer of Warranties and Limitation of Liability. 136 | 137 | a. **Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.** 138 | 139 | b. **To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.** 140 | 141 | c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. 142 | 143 | #### Section 6 – Term and Termination. 144 | 145 | a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. 146 | 147 | b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 148 | 149 | 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 150 | 151 | 2. upon express reinstatement by the Licensor. 152 | 153 | For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 154 | 155 | c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 156 | 157 | d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. 158 | 159 | #### Section 7 – Other Terms and Conditions. 160 | 161 | a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. 162 | 163 | b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. 164 | 165 | #### Section 8 – Interpretation. 166 | 167 | a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. 168 | 169 | b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. 170 | 171 | c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. 172 | 173 | d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. 174 | 175 | > Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” The text of the Creative Commons public licenses is dedicated to the public domain under the [CC0 Public Domain Dedication](https://creativecommons.org/publicdomain/zero/1.0/legalcode). Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](http://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. 176 | > 177 | > Creative Commons may be contacted at creativecommons.org. 178 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | --- 2 | weight: 1 3 | --- 4 | 5 | # Architecture 6 | 7 | ### Overview 8 | 9 | ![image](./img/diagram.svg "Diagram") 10 | 11 | ### Components 12 | 13 | #### Proxy 14 | 15 | A web server that forwards JSON-RPC requests between the wallet and the simulated chain. Necessary because browsers do not allow web pages to listen for HTTP or WebSocket connections 16 | 17 | #### Wallet 18 | 19 | The software under test. Communicates with the tests using `window.ethereum`, and with the simulated chain through the proxy with JSON-RPC. 20 | 21 | #### Simulated Chain 22 | 23 | An isolated Ethereum-compatible blockchain (like [Ganache](https://github.com/trufflesuite/ganache).) The simulated chain presents a JSON-RPC interface to the wallet through the proxy. 24 | 25 | #### Tests 26 | 27 | Collection of functions that put the simulated chain into a known state, then perform some operation with or retrieve some information through the wallet. 28 | 29 | #### Glue 30 | 31 | Software specific to each wallet that translates conceptual actions (eg. approve transaction) into a format that the wallet understands. The framework provides a [manual glue](./guide-manual.md) that works with all wallets, but requires human interaction. For specific wallets, the framework also provides fully automated glue implementations that can run without a human. See [Writing Glue](./guide-glue.md) for how to create your own glue. 32 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | --- 2 | weight: 2 3 | --- 4 | 5 | # Development 6 | 7 | ## Prerequisites 8 | 9 | - NodeJS >= 18.14.2 10 | - A browser that [supports ES2020](https://caniuse.com/sr_es11) (like Brave or Firefox). 11 | - An Ethereum wallet that supports custom RPC endpoints (see our [most recent test report](https://wtf.allwallet.dev/categories/test-report/) for options). 12 | - Git 13 | 14 | ## Preparing your Environment 15 | 16 | ### Getting the Code 17 | 18 | Quickest way to get started is by cloning the [WTF](https://github.com/wallet-test-framework/framework) repository: 19 | 20 | ```bash 21 | git clone git@github.com:wallet-test-framework/framework.git 22 | ``` 23 | 24 | ### Installing Dependencies 25 | 26 | The project uses `npm` as a package manager. You can install the required modules by running: 27 | 28 | ```bash 29 | npm install --include=dev 30 | ``` 31 | 32 | ## Running 33 | 34 | To build and run the project, run: 35 | 36 | ```bash 37 | npm run build && npm start 38 | ``` 39 | 40 | Then open your browser to . See [The Manual Glue & You](./guide-manual.md) for detailed instructions on how to use the manual glue. 41 | -------------------------------------------------------------------------------- /docs/guide-glue.md: -------------------------------------------------------------------------------- 1 | --- 2 | weight: 4 3 | --- 4 | 5 | # Writing Glue 6 | 7 | See [Development](./development.md) for setup instructions. 8 | 9 | In WTF, glue is software that connects the tests to individual wallets. This layer of abstraction allows the test to be agnostic to the details of how each wallet works. 10 | 11 | ## Choosing Automation Framework 12 | 13 | The first step in writing glue is choosing the automation framework you would like to use. This choice is primarily dictated by the platform and UI library your wallet uses. Currently all WTF glues use [selenium-webdriver](https://www.npmjs.com/package/selenium-webdriver), though other options could include [Appium](https://www.npmjs.com/package/appium) or [Dogtail](https://gitlab.com/dogtail/dogtail). 14 | 15 | If your wallet runs in a browser, Selenium WebDriver is the likely candidate. 16 | 17 | ## First steps 18 | 19 | !!! note "Are you using selenium-webdriver and glue-ws?" 20 | 21 | If your wallet is close enough to one of our existing glues (like [glue-taho](https://github.com/wallet-test-framework/glue-taho) or [glue-coinbase](https://github.com/wallet-test-framework/glue-coinbase)), it will be easier to start by cloning that glue instead of starting from scratch. 22 | 23 | Once you have chosen your automation framework, the next step is to subclass [`Glue`](https://github.com/wallet-test-framework/glue/blob/f9bf7497562d1da616320c1f1d133a61e94bc4fd/src/index.ts#L7). WTF provides a default implementation using WebSockets: [glue-ws](https://github.com/wallet-test-framework/glue-ws). If glue-ws is acceptable (it probably is), you can setup a project using the following: 24 | 25 | ```bash 26 | mkdir glue-mywallet 27 | cd glue-mywallet 28 | npm init 29 | # customize the project to your needs 30 | npm i @wallet-test-framework/glue-ws 31 | ``` 32 | 33 | In `index.ts`: 34 | 35 | ```typescript 36 | import serveGlue, { ServeResult } from "@wallet-test-framework/glue-ws"; 37 | import * as process from "node:process"; 38 | 39 | // MyWalletGlue should be your subclass of Glue. 40 | const implementation = new MyWalletGlue(); 41 | 42 | // serveGlue spawns a websocket server to handle requests from tests. 43 | const serveResult = serveGlue(implementation, { port: 0 }); 44 | 45 | // testUrl is the location of the WTF frontend dapp. 46 | const testUrl = "https://wallet-test-framework.herokuapp.com/"; 47 | 48 | async function start(testUrl, implementation, serveResult) { 49 | // This function should prepare everything your glue needs to automate 50 | // a wallet. For example, for a browser-based wallet, this function would 51 | // launch a browser, install the wallet extension, navigate to testUrl, and 52 | // click the connect button. 53 | } 54 | 55 | try { 56 | // Perform setup and launch the tests. 57 | await start(testUrl, implementation, serveResult); 58 | 59 | // Collect the test results. 60 | const report = await implementation.reportReady; 61 | 62 | if (typeof report.value !== "string") { 63 | throw new Error("unsupported report type"); 64 | } 65 | 66 | process.stdout.write(report.value); 67 | } finally { 68 | await serveResult.close(); 69 | } 70 | ``` 71 | 72 | ## Subclassing `Glue` 73 | 74 | Regardless whether you're using [glue-ws](https://github.com/wallet-test-framework/glue-ws) or not, eventually you will need to subclass `Glue`. There are several key methods that need to be overridden (for example: `activateChain`, `sendTransaction`, and `addEthereumChain`), and several key events that need to be emitted (for example: `requestaccounts`). 75 | 76 | ### Responding to Dialogs 77 | 78 | These methods are invoked by tests to respond to dialogs opened by the wallet (see [`index.ts`](https://github.com/wallet-test-framework/glue/blob/master/src/index.ts) for an exhaustive list), and beside each is the most common JSON-RPC endpoint that causes the dialog. 79 | 80 | - `requestAccounts` (`eth_requestAccounts`) 81 | - `signMessage` (`eth_sign`) 82 | - `sendTransaction` (`eth_sendTransaction`) 83 | - `signTransaction` (`eth_signTransaction`) 84 | - `switchEthereumChain` (`wallet_switchEthereumChain`) 85 | - `addEthereumChain` (`wallet_addEthereumChain`) 86 | 87 | The implementations of theses methods should interact with the wallet UI. In the case of browser extension wallets, for example, these methods use selenium-webdriver to fill in text boxes and click buttons. They shouldn't call into private wallet APIs because then it wouldn't be true end-to-end testing. 88 | 89 | ### Utility Methods 90 | 91 | There are two non-dialog related methods: 92 | 93 | - `activateChain` requests that the wallet add a new EVM compatible chain with a given chainID and RPC URL. 94 | - `report` passes the completed test report (in xunit format) to the glue for display/processing. 95 | 96 | ### Events 97 | 98 | Events are how the glue reports back to the tests. Events usually represent dialogs. The following is a list of events, and the most common JSON-RPC endpoint that triggers them. 99 | 100 | - `requestaccounts` (`eth_requestAccounts`) 101 | - `signmessage` (`eth_sign`) 102 | - `sendtransaction` (`eth_sendTransaction`) 103 | - `signtranasction` (`eth_signTransaction`) 104 | - `addethereumchain` (`wallet_addEthereumChain`) 105 | - `switchethereumchain` (`wallet_switchEthereumChain`) 106 | -------------------------------------------------------------------------------- /docs/guide-manual.md: -------------------------------------------------------------------------------- 1 | --- 2 | weight: 5 3 | --- 4 | 5 | # The Manual Glue & You 6 | 7 | The manual glue is the most generic way to try Wallet Test Framework. It displays instructions to a human user and generates events based on button presses and form inputs. Ideally every wallet will have bespoke integration, but the manual glue works even when no integration has been written yet. 8 | 9 | ## Requirements 10 | 11 | At minimum, your wallet must support: 12 | 13 | - Custom RPC Endpoints (e.g. [`wallet_addEtherereumChain`](https://eips.ethereum.org/EIPS/eip-3085)) 14 | - Needs to inject [`window.ethereum`](https://eips.ethereum.org/EIPS/eip-1193) 15 | 16 | _Mobile wallet and WalletConnect support coming soon!_ 17 | 18 | ## Options 19 | 20 | You can choose what tests to run by appending a `grep` query parameter: 21 | 22 | ``` 23 | http://localhost:3000/?grep=sendTransaction 24 | ``` 25 | 26 | ## Using the Manual Glue 27 | 28 | !!! warning "Do not click any wallet buttons without being instructed to!" 29 | 30 | The manual glue will provide explicit instructions on when to approve/reject prompts from your wallet. Clicking buttons before being asked to will cause tests to fail. 31 | 32 | ### Connecting your Wallet 33 | 34 | ![image](./img/glue-001.png) 35 | The first page that greets you from Wallet Test Framework is the connect page. Clicking the connect button will establish a connection from the tests to your wallet. 36 | 37 | ### Explaining the User Interface 38 | 39 | The user interface is divided into three columns: test results, events, and instructions. 40 | ![image](./img/glue-002.png) 41 | 42 | The results column will contain the names of tests and their pass/fail status. The middle column (events) has buttons that you press when your wallet requests user input. The last column (instructions) will provide the responses to give to the wallet. 43 | 44 | ### Requesting User Input 45 | 46 | Here is a hypothetical prompt that your wallet might display. 47 | ![image](./img/glue-003.png) 48 | 49 | To inform the test that the wallet is requesting information, choose the correct button from the center column. In this case it's "Add Ethereum Chain": 50 | 51 | ![image](./img/glue-004.png) 52 | 53 | In the dialog that opens, copy the relevant information from your wallet and click submit. 54 | 55 | ![image](./img/glue-005.png) 56 | 57 | ### Responding to a Prompt 58 | 59 | After reporting an event, WTF may give the user instructions to follow. These will be displayed in the rightmost column: 60 | 61 | ![image](./img/glue-006.png) 62 | 63 | Make sure to follow the instructions before clicking complete. 64 | 65 | ### Results 66 | 67 | ![image](./img/glue-007.png) 68 | -------------------------------------------------------------------------------- /docs/guide-testing.md: -------------------------------------------------------------------------------- 1 | --- 2 | weight: 2.5 3 | --- 4 | 5 | # Writing your First Test 6 | 7 | See [Development](./development.md) for setup instructions. 8 | 9 | ## Adding a Test 10 | 11 | The tests can be found in [`client/src/tests/eth`](https://github.com/wallet-test-framework/framework/tree/master/client/src/tests/eth), organized into files based on the RPC endpoint being tested. If your test fits in an existing file, you may add it accordingly. If not, add a new file following the current convention, and update the list of test files found in [`/client/src/tests.ts`](https://github.com/wallet-test-framework/framework/blob/master/client/src/tests.ts). 12 | 13 | Tests under the `eth` directory are meant to be general purpose, and should apply to any wallet. 14 | 15 | ### Libraries 16 | 17 | The tests use [Mocha](https://www.npmjs.com/package/mocha) ([docs](https://mochajs.org/#table-of-contents)) as the test runner, and use [Assert](https://www.npmjs.com/package/assert) ([docs](https://nodejs.org/api/assert.html)) for assertions. 18 | 19 | To interact with the blockchain and wallet, the tests use [Viem](https://www.npmjs.com/package/viem) ([docs](https://viem.sh/docs/getting-started)). 20 | 21 | ### Boilerplate 22 | 23 | The following is an example of a trivial test: 24 | 25 | ```typescript 26 | import * as tests from "../../tests"; 27 | import assert from "assert"; 28 | 29 | const blockchain = tests.blockchain; 30 | const wallet = tests.wallet; 31 | 32 | if (!blockchain || !wallet) { 33 | throw "not ready"; 34 | } 35 | 36 | describe("accounts", () => { 37 | it("returns a list of accounts", async () => { 38 | const accounts = await wallet.wallet.getAddresses(); 39 | 40 | for (const account of accounts) { 41 | assert.ok(account.startsWith("0x"), "account is a hex string"); 42 | } 43 | }); 44 | }); 45 | ``` 46 | 47 | In this example, we create a suite of tests called `accounts` with a single test `returns a list of accounts`. 48 | 49 | ### Manipulating the Blockchain 50 | 51 | The simulated blockchain may be controlled using the `blockchain` variable that is provided to every test file. Here's an example of how to mine a block: 52 | 53 | ```typescript 54 | await blockchain.test.mine({ blocks: 1 }); 55 | ``` 56 | 57 | The available functions are documented [here](https://viem.sh/docs/actions/test/introduction). 58 | 59 | ### Interacting with the Wallet 60 | 61 | #### Initiating an Action 62 | 63 | Beginning an action is no different than a standard viem call. For example to send a transaction: 64 | 65 | ```typescript 66 | const responsePromise = wallet.wallet.sendTransaction({ 67 | account: sender, 68 | to: sender, 69 | value: value, 70 | }); 71 | ``` 72 | 73 | To support automated glue, there is a bit more to do. 74 | 75 | #### Responding to Prompts 76 | 77 | Many wallet operations will require some user input, ranging from approving a transaction (pictured below) to filling out a form for adding a new network. Providing this user input is the job of the test writer. Wallet Test Framework provides some abstractions to make dealing with a variety of wallets easier. 78 | 79 | ![image](./img/metamask.png) 80 | 81 | Continuing from the above example, here's how to wait for the wallet dialog: 82 | 83 | ```typescript 84 | const sendEvent = await wallet.glue.next("sendtransaction"); 85 | ``` 86 | 87 | The returned event will contain information about the dialog the wallet displayed. In the case of `sendtransaction`, it will have the sending address (`from`), the destination address (`to`), etc. 88 | 89 | To click a button in the wallet, call the appropriate method on `wallet.glue`. For example, to approve the sending of the transaction: 90 | 91 | ```typescript 92 | await wallet.glue.sendTransaction({ 93 | id: sendEvent.id, 94 | action: "approve", 95 | }); 96 | ``` 97 | 98 | ### Trying your Test 99 | 100 | To execute a suite of tests, you can use the `grep` query parameter: 101 | 102 | ``` 103 | http://localhost:3000/?grep=estimateGas 104 | ``` 105 | 106 | Where `estimateGas` is the first argument passed to Mocha's `describe`. 107 | 108 | For an up to date list of tests, see [`client/src/tests/eth`](https://github.com/wallet-test-framework/framework/tree/master/client/src/tests/eth). See [The Manual Glue & You](./guide-manual.md) for detailed instructions on how to use the manual glue. 109 | 110 | ## Coding Standards 111 | 112 | Our coding standards are enforced by [Prettier](https://www.npmjs.com/package/prettier), and [ESLint](https://www.npmjs.com/package/eslint). 113 | 114 | To check for issues, use: 115 | 116 | ```bash 117 | npm test 118 | ``` 119 | 120 | To automatically fix issues, use: 121 | 122 | ```bash 123 | npm run fmt 124 | ``` 125 | -------------------------------------------------------------------------------- /docs/img/glue-001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallet-test-framework/framework/759471c849a8b48e6170d3c4cf3ffd99260a4094/docs/img/glue-001.png -------------------------------------------------------------------------------- /docs/img/glue-002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallet-test-framework/framework/759471c849a8b48e6170d3c4cf3ffd99260a4094/docs/img/glue-002.png -------------------------------------------------------------------------------- /docs/img/glue-003.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallet-test-framework/framework/759471c849a8b48e6170d3c4cf3ffd99260a4094/docs/img/glue-003.png -------------------------------------------------------------------------------- /docs/img/glue-004.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallet-test-framework/framework/759471c849a8b48e6170d3c4cf3ffd99260a4094/docs/img/glue-004.png -------------------------------------------------------------------------------- /docs/img/glue-005.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallet-test-framework/framework/759471c849a8b48e6170d3c4cf3ffd99260a4094/docs/img/glue-005.png -------------------------------------------------------------------------------- /docs/img/glue-006.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallet-test-framework/framework/759471c849a8b48e6170d3c4cf3ffd99260a4094/docs/img/glue-006.png -------------------------------------------------------------------------------- /docs/img/glue-007.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallet-test-framework/framework/759471c849a8b48e6170d3c4cf3ffd99260a4094/docs/img/glue-007.png -------------------------------------------------------------------------------- /docs/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 38 | 39 | 47 | -------------------------------------------------------------------------------- /docs/img/metamask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wallet-test-framework/framework/759471c849a8b48e6170d3c4cf3ffd99260a4094/docs/img/metamask.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | Wallets are the most important point of interaction for users on-boarding to the Ethereum ecosystem, and there are a lot of different wallets. While wallets are free to offer whatever advanced features they'd like, every wallet must support the same basic operations like transfers, displaying balances, and so on. This set of common features is the perfect place for automated testing. 4 | 5 | Wallet Test Framework is that automated testing. 6 | 7 | We'd like to see the UX quality among wallets improve, without a ton of duplicated effort. That's why we are building Wallet Test Framework: an automated testing framework that covers the basics, so wallet teams can build value-add features without sacrificing their foundation. 8 | 9 | ## Source Code 10 | 11 | - [Framework](https://github.com/Wallet-Test-Framework/framework) 12 | - [Glue](https://github.com/wallet-test-framework/glue) 13 | - [Glue-ws](https://github.com/wallet-test-framework/glue-ws) 14 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Wallet Test Framework 2 | site_url: https://wallet-test-framework.github.io/framework 3 | theme: 4 | logo: img/logo.svg 5 | favicon: img/logo.svg 6 | name: material 7 | features: 8 | - content.code.copy 9 | repo_url: https://github.com/wallet-test-framework/framework 10 | extra: 11 | homepage: https://wtf.allwallet.dev 12 | copyright: Copyright © 2024 Binary Cake Ltd. - CC BY-SA 4.0 13 | markdown_extensions: 14 | - pymdownx.highlight: 15 | anchor_linenums: true 16 | line_spans: __span 17 | pygments_lang_class: true 18 | - pymdownx.inlinehilite 19 | - pymdownx.snippets 20 | - pymdownx.superfences 21 | - admonition 22 | 23 | plugins: 24 | - mkdocs-nav-weight 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wallet-test-framework/framework", 3 | "version": "0.10.0-dev", 4 | "description": "a suite of tests for Ethereum wallets", 5 | "license": "MIT", 6 | "type": "module", 7 | "exports": { 8 | ".": { 9 | "node": "./dist/server/index.js", 10 | "browser": "./dist/client/index.js" 11 | }, 12 | "./worker_chain.js": { 13 | "browser": "./dist/client/worker_chain.js" 14 | } 15 | }, 16 | "bin": { 17 | "wtfd": "./dist/server/index.js" 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "scripts": { 23 | "fmt": "prettier --write .", 24 | "build:client": "node ./client/Build.js && tsc -d --emitDeclarationOnly -p client", 25 | "build:server": "node ./server/Build.js && tsc -d --emitDeclarationOnly -p server", 26 | "build": "npm run build:client && npm run build:server", 27 | "test": "prettier --check . && npm run build && eslint .", 28 | "start": "node ./dist/server/index.js" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/wallet-test-framework/framework.git" 33 | }, 34 | "contributors": [ 35 | { 36 | "name": "Nikki Gaudreau", 37 | "url": "https://twitter.com/gaudren_", 38 | "email": "nikki@binarycake.ca" 39 | }, 40 | { 41 | "name": "Sam Wilson", 42 | "url": "https://twitter.com/_SamWilsn_", 43 | "email": "sam@binarycake.ca" 44 | } 45 | ], 46 | "bugs": { 47 | "url": "https://github.com/wallet-test-framework/framework/issues" 48 | }, 49 | "homepage": "https://wtf.allwallet.dev/", 50 | "engines": { 51 | "node": ">=18.14.2 <20.8" 52 | }, 53 | "devDependencies": { 54 | "@jgoz/esbuild-plugin-typecheck": "^3.1.1", 55 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 56 | "@tsconfig/recommended": "^1.0.3", 57 | "@types/cors": "^2.8.17", 58 | "@types/express": "^4.17.21", 59 | "@types/mocha": "^10.0.6", 60 | "@types/serve-static": "^1.15.5", 61 | "@types/ws": "^8.5.10", 62 | "@typescript-eslint/eslint-plugin": "7.1.1", 63 | "@typescript-eslint/parser": "^7.1.1", 64 | "@wallet-test-framework/glue": "^0.8.0", 65 | "@walletconnect/ethereum-provider": "^2.14.0", 66 | "assert": "^2.1.0", 67 | "esbuild": "0.20.1", 68 | "eslint": "8.57.0", 69 | "eslint-config-prettier": "^9.1.0", 70 | "ganache": "7.9.0", 71 | "mocha": "^10.3.0", 72 | "prettier": "3.2.5", 73 | "process": "^0.11.10", 74 | "rpc-websockets": "^7.9.0", 75 | "solc": "0.8.24", 76 | "typescript": "^5.4.2", 77 | "viem": "^2.7.20" 78 | }, 79 | "dependencies": { 80 | "cors": "^2.8.5", 81 | "express": "^4.18.3", 82 | "lru-cache": "^10.2.0", 83 | "raw-body": "^2.5.2", 84 | "serve-static": "^1.15.0", 85 | "ws": "^8.16.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /server/Build.js: -------------------------------------------------------------------------------- 1 | import { typecheckPlugin } from "@jgoz/esbuild-plugin-typecheck"; 2 | import * as esbuild from "esbuild"; 3 | import process from "node:process"; 4 | import { URL, fileURLToPath, pathToFileURL } from "node:url"; 5 | 6 | const options = { 7 | plugins: [typecheckPlugin()], 8 | 9 | absWorkingDir: fileURLToPath(new URL(".", import.meta.url)), 10 | 11 | entryPoints: ["src/index.ts", "src/client.ts", "src/connections.ts"], 12 | 13 | outbase: "src", 14 | outdir: "../dist/server/", 15 | target: "es2020", 16 | format: "esm", 17 | platform: "node", 18 | minify: true, 19 | sourcemap: true, 20 | }; 21 | 22 | if (import.meta.url === pathToFileURL(process.argv[1]).href) { 23 | await esbuild.build(options); 24 | } 25 | 26 | export { options as default }; 27 | -------------------------------------------------------------------------------- /server/src/client.ts: -------------------------------------------------------------------------------- 1 | import { LRUCache } from "lru-cache"; 2 | import http from "node:http"; 3 | import { RawData, WebSocket } from "ws"; 4 | 5 | export class ClientState { 6 | private readonly inFlight: LRUCache; 7 | private requestCount = 0; 8 | 9 | public readonly connection: WebSocket; 10 | 11 | constructor(connection_: WebSocket) { 12 | this.inFlight = new LRUCache({ 13 | max: 16, 14 | dispose: (value, key) => this.disposeInFlight(value, key), 15 | }); 16 | this.connection = connection_; 17 | this.connection.on("message", (data) => this.onMessage(data)); 18 | } 19 | 20 | private onMessage(data: RawData): void { 21 | let key: unknown; 22 | let result: unknown; 23 | let parsed: unknown; 24 | 25 | try { 26 | // It's totally fine if `toString` returns `"[object Object]"`, 27 | // because that'll fail to parse as valid JSON. 28 | 29 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 30 | parsed = JSON.parse(data.toString()); 31 | } catch (err) { 32 | console.debug("Invalid WebSocket message", err); 33 | } 34 | 35 | if (typeof parsed === "object" && parsed) { 36 | key = "number" in parsed ? parsed.number : undefined; 37 | result = "result" in parsed ? parsed.result : undefined; 38 | } 39 | 40 | if (typeof key !== "number" || typeof result !== "object") { 41 | this.connection.close(400); 42 | // TODO: Close connections in inFlight. 43 | return; 44 | } 45 | 46 | const res = this.inFlight.get(key); 47 | if (!res) { 48 | console.debug("Unexpected response"); 49 | return; 50 | } 51 | 52 | // See: https://github.com/isaacs/node-lru-cache/issues/291 53 | this.inFlight.set(key, false, { noDisposeOnSet: true }); 54 | this.inFlight.delete(key); 55 | 56 | console.debug("Proxying response", key); 57 | res.writeHead(200).end(JSON.stringify(result)); 58 | } 59 | 60 | private disposeInFlight( 61 | value: http.ServerResponse | false, 62 | key: number, 63 | ): void { 64 | if (!value) { 65 | return; 66 | } 67 | 68 | console.debug("Evicting request", key); 69 | 70 | // Send back an error. 71 | value 72 | .writeHead(504, "too many in-flight requests", { 73 | "Content-Type": "application/json", 74 | }) 75 | .end( 76 | JSON.stringify({ 77 | jsonrpc: "2.0", 78 | error: { 79 | code: -32603, 80 | message: "wtf: too many in-flight requests", 81 | }, 82 | id: null, 83 | }), 84 | ); 85 | } 86 | 87 | public request(request: Buffer, res: http.ServerResponse): void { 88 | const requestId = this.requestCount++; 89 | this.inFlight.set(requestId, res); 90 | 91 | let body: unknown; 92 | 93 | try { 94 | body = JSON.parse(request.toString()); 95 | } catch (e) { 96 | console.debug("Invalid JSON-RPC request", requestId); 97 | this.inFlight.delete(requestId); 98 | res.writeHead(400).end(); 99 | return; 100 | } 101 | 102 | // Send the request to the WebSocket. 103 | this.connection.send( 104 | JSON.stringify({ 105 | number: requestId, 106 | body, 107 | }), 108 | ); 109 | } 110 | 111 | public dispose(): void { 112 | this.connection.close(504); 113 | this.inFlight.clear(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /server/src/connections.ts: -------------------------------------------------------------------------------- 1 | import { ClientState } from "./client.js"; 2 | import { LRUCache } from "lru-cache"; 3 | import { Buffer } from "node:buffer"; 4 | import { ServerResponse } from "node:http"; 5 | import { WebSocket } from "ws"; 6 | 7 | export class Connections { 8 | readonly connections: LRUCache; 9 | 10 | constructor() { 11 | this.connections = new LRUCache({ 12 | max: 32, 13 | dispose: (value, key) => this.disposeConnection(value, key), 14 | }); 15 | } 16 | 17 | private disposeConnection(value: ClientState, key: string): void { 18 | console.debug("Evicting connection", key); 19 | value.dispose(); 20 | } 21 | 22 | public rpcRequest( 23 | key: string, 24 | request: Buffer, 25 | response: ServerResponse, 26 | ): void { 27 | const maybeClient = this.connections.get(key); 28 | 29 | if (maybeClient) { 30 | console.debug("Proxying request", key); 31 | maybeClient.request(request, response); 32 | } else { 33 | console.debug("Rejecting request (no WebSocket)", key); 34 | 35 | // Send back an error. 36 | response 37 | .writeHead(502, "no WebSocket connection", { 38 | "Content-Type": "application/json", 39 | }) 40 | .end( 41 | JSON.stringify({ 42 | jsonrpc: "2.0", 43 | error: { 44 | code: -32603, 45 | message: "wtf: no WebSocket connection", 46 | }, 47 | id: null, 48 | }), 49 | ); 50 | } 51 | } 52 | 53 | public wsConnect(key: string, ws: WebSocket): void { 54 | ws.on("close", () => this.delete(key)); 55 | ws.on("error", () => this.delete(key)); 56 | ws.on("pong", () => console.debug("Pong", key)); 57 | 58 | const state = new ClientState(ws); 59 | const maybeClient = this.connections.get(key); 60 | 61 | if (maybeClient) { 62 | // Assume the existing connection is the "real" one. 63 | ws.close(); 64 | } else { 65 | this.connections.set(key, state); 66 | } 67 | } 68 | 69 | private delete(key: string): void { 70 | console.debug("Deleting connection", key); 71 | 72 | const maybeClient = this.connections.get(key); 73 | if (!maybeClient) { 74 | return; 75 | } 76 | 77 | this.connections.delete(key); 78 | maybeClient.dispose(); // TODO: Sends the wrong error code & message. 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Connections } from "./connections.js"; 3 | import cors from "cors"; 4 | import express from "express"; 5 | import process from "node:process"; 6 | import { fileURLToPath } from "node:url"; 7 | import { URL } from "node:url"; 8 | import getRawBody from "raw-body"; 9 | import serveStatic from "serve-static"; 10 | import { WebSocketServer } from "ws"; 11 | 12 | const app = express(); 13 | 14 | const WEB_ROOT = fileURLToPath(new URL("../client", import.meta.url)); 15 | 16 | const connections = new Connections(); 17 | 18 | app.use(cors()); 19 | app.use(serveStatic(WEB_ROOT)); 20 | 21 | function rpcRoute(req: express.Request, res: express.Response): void { 22 | const key = req.params["key"]; 23 | 24 | console.debug("Received HTTP request", key, req.method); 25 | 26 | getRawBody( 27 | req, 28 | { length: req.headers["content-length"], limit: 2 * 1024 * 1024 }, 29 | (err, body) => { 30 | if (err) { 31 | console.debug("Reading HTTP body failed", err); 32 | res.writeHead(err.statusCode).end(err.message); 33 | return; 34 | } 35 | 36 | connections.rpcRequest(key, body, res); 37 | }, 38 | ); 39 | } 40 | 41 | app.post("/rpc/:key", rpcRoute); 42 | app.get("/rpc/:key", rpcRoute); 43 | 44 | // Create server 45 | const PORT = "PORT" in process.env ? parseInt(process.env.PORT || "") : 3000; 46 | const server = app.listen(PORT, () => { 47 | console.info(`Listening on ${PORT}...`); 48 | }); 49 | 50 | const wss = new WebSocketServer({ server }); 51 | 52 | wss.on("connection", (ws, req) => { 53 | console.info( 54 | "Incoming WebSocket", 55 | req.url, 56 | req.socket.remoteAddress, 57 | req.socket.remotePort, 58 | ); 59 | 60 | const url = req.url; 61 | 62 | if (!url) { 63 | ws.close(); 64 | return; 65 | } 66 | 67 | connections.wsConnect(url.slice(1), ws); 68 | }); 69 | 70 | setInterval(() => { 71 | wss.clients.forEach((client) => { 72 | client.ping(); 73 | }); 74 | }, 10000); 75 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationDir": "../dist/server/" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json", 3 | 4 | "compilerOptions": { 5 | "moduleResolution": "node", 6 | "module": "es2020", 7 | "target": "es2020", 8 | "isolatedModules": true 9 | } 10 | } 11 | --------------------------------------------------------------------------------