From d57e7c1a8397ad75d9b8afedf927b0139a3710df Mon Sep 17 00:00:00 2001 From: Copybara Bot Date: Tue, 2 Jun 2026 08:30:36 +0000 Subject: [PATCH] Sync 436f5fa4a2bc7b490c29f1492944aca1aea81fd6 FolderOrigin-RevId: 436f5fa4a2bc7b490c29f1492944aca1aea81fd6 --- .gitignore | 4 + Cargo.lock | 304 ++++++- Cargo.toml | 3 + README.md | 2 +- app/tsconfig.json | 1 + app/yarn.lock | 786 +++++++++++++++++- beam-apps/README.md | 71 ++ beam-apps/apps/uniswap/Cargo.lock | 255 ++++++ beam-apps/apps/uniswap/Cargo.toml | 19 + beam-apps/apps/uniswap/README.md | 84 ++ beam-apps/apps/uniswap/assets/icon.svg | 11 + beam-apps/apps/uniswap/build.rs | 18 + beam-apps/apps/uniswap/manifest.json | 234 ++++++ beam-apps/apps/uniswap/src/api.rs | 174 ++++ beam-apps/apps/uniswap/src/args.rs | 109 +++ beam-apps/apps/uniswap/src/error.rs | 28 + beam-apps/apps/uniswap/src/host.rs | 55 ++ beam-apps/apps/uniswap/src/lib.rs | 40 + beam-apps/apps/uniswap/src/plan.rs | 277 ++++++ beam-apps/apps/uniswap/src/tests.rs | 174 ++++ beam-apps/fixtures/README.md | 12 + .../apps/uniswap/1.0.0/icon.svg | 11 + .../apps/uniswap/1.0.0/manifest.json | 224 +++++ .../apps/uniswap/1.0.0/manifest.json.sig | 5 + .../apps/uniswap/1.0.0/module.wasm | Bin 0 -> 593 bytes .../apps/uniswap/1.0.0/version.json.sig | 5 + .../fixtures/broad-wildcard/catalog/apps.json | 109 +++ .../broad-wildcard/catalog/apps.json.sig | 5 + .../broad-wildcard/catalog/apps/uniswap.json | 298 +++++++ .../catalog/apps/uniswap.json.sig | 5 + beam-apps/fixtures/broad-wildcard/index.json | 32 + .../fixtures/broad-wildcard/index.json.sig | 5 + .../apps/uniswap/1.0.0/icon.svg | 11 + .../apps/uniswap/1.0.0/manifest.json | 295 +++++++ .../apps/uniswap/1.0.0/manifest.json.sig | 5 + .../apps/uniswap/1.0.0/module.wasm | Bin 0 -> 593 bytes .../apps/uniswap/1.0.0/version.json.sig | 5 + .../fixtures/invalid-digest/catalog/apps.json | 109 +++ .../invalid-digest/catalog/apps.json.sig | 5 + .../invalid-digest/catalog/apps/uniswap.json | 298 +++++++ .../catalog/apps/uniswap.json.sig | 5 + beam-apps/fixtures/invalid-digest/index.json | 32 + .../fixtures/invalid-digest/index.json.sig | 5 + .../apps/uniswap/1.0.0/icon.svg | 11 + .../apps/uniswap/1.0.0/manifest.json | 226 +++++ .../apps/uniswap/1.0.0/manifest.json.sig | 5 + .../apps/uniswap/1.0.0/module.wasm | Bin 0 -> 593 bytes .../apps/uniswap/1.0.0/version.json.sig | 5 + .../malformed-permissions/catalog/apps.json | 109 +++ .../catalog/apps.json.sig | 5 + .../catalog/apps/uniswap.json | 298 +++++++ .../catalog/apps/uniswap.json.sig | 5 + .../fixtures/malformed-permissions/index.json | 32 + .../malformed-permissions/index.json.sig | 5 + .../apps/uniswap/1.0.0/icon.svg | 11 + .../apps/uniswap/1.0.0/manifest.json | 134 +++ .../apps/uniswap/1.0.0/manifest.json.sig | 5 + .../apps/uniswap/1.0.0/module.wasm | Bin 0 -> 593 bytes .../apps/uniswap/1.0.0/version.json.sig | 5 + .../fixtures/missing-fields/catalog/apps.json | 109 +++ .../missing-fields/catalog/apps.json.sig | 5 + .../missing-fields/catalog/apps/uniswap.json | 298 +++++++ .../catalog/apps/uniswap.json.sig | 5 + beam-apps/fixtures/missing-fields/index.json | 32 + .../fixtures/missing-fields/index.json.sig | 5 + .../apps/uniswap/1.0.0/icon.svg | 11 + .../apps/uniswap/1.0.0/manifest.json | 295 +++++++ .../apps/uniswap/1.0.0/manifest.json.sig | 5 + .../apps/uniswap/1.0.0/module.wasm | Bin 0 -> 593 bytes .../apps/uniswap/1.0.0/version.json.sig | 5 + .../unsupported-beam/catalog/apps.json | 109 +++ .../unsupported-beam/catalog/apps.json.sig | 5 + .../catalog/apps/uniswap.json | 298 +++++++ .../catalog/apps/uniswap.json.sig | 5 + .../fixtures/unsupported-beam/index.json | 32 + .../fixtures/unsupported-beam/index.json.sig | 5 + .../valid/apps/uniswap/1.0.0/icon.svg | 11 + .../valid/apps/uniswap/1.0.0/manifest.json | 295 +++++++ .../apps/uniswap/1.0.0/manifest.json.sig | 5 + .../valid/apps/uniswap/1.0.0/module.wasm | Bin 0 -> 593 bytes .../valid/apps/uniswap/1.0.0/version.json.sig | 5 + beam-apps/fixtures/valid/catalog/apps.json | 109 +++ .../fixtures/valid/catalog/apps.json.sig | 5 + .../fixtures/valid/catalog/apps/uniswap.json | 298 +++++++ .../valid/catalog/apps/uniswap.json.sig | 5 + beam-apps/fixtures/valid/index.json | 32 + beam-apps/fixtures/valid/index.json.sig | 5 + beam-apps/registry-nginx.conf | 39 + docker/Dockerfile.beam-app-registry | 4 + docker/docker-compose.yml | 2 +- docs/public/SUMMARY.md | 9 + docs/public/protocol/specs/README.md | 6 + .../specs/privacy-protocol/circuits.mdx | 1 + .../specs/privacy-protocol/contract.mdx | 1 + .../protocol/specs/privacy-protocol/data.mdx | 1 + .../specs/privacy-protocol/encryption.mdx | 1 + .../specs/privacy-protocol/lookup.mdx | 1 + .../privacy-protocol/privacy-protocol.mdx | 1 + .../specs/privacy-protocol/security.mdx | 1 + .../specs/privacy-protocol/wallet.mdx | 1 + docs/specs/README.md | 11 + docs/specs/privacy-protocol/circuits.mdx | 452 ++++++++++ docs/specs/privacy-protocol/contract.mdx | 363 ++++++++ docs/specs/privacy-protocol/data.mdx | 217 +++++ docs/specs/privacy-protocol/encryption.mdx | 199 +++++ docs/specs/privacy-protocol/lookup.mdx | 141 ++++ .../privacy-protocol/privacy-protocol.mdx | 75 ++ docs/specs/privacy-protocol/security.mdx | 75 ++ docs/specs/privacy-protocol/wallet.mdx | 79 ++ pkg/beam-cli/Cargo.toml | 6 +- pkg/beam-cli/README.md | 131 ++- pkg/beam-cli/src/apps/approvals.rs | 187 +++++ pkg/beam-cli/src/apps/error.rs | 130 +++ pkg/beam-cli/src/apps/host.rs | 516 ++++++++++++ pkg/beam-cli/src/apps/mod.rs | 12 + pkg/beam-cli/src/apps/model.rs | 291 +++++++ pkg/beam-cli/src/apps/permissions.rs | 79 ++ pkg/beam-cli/src/apps/privacy.rs | 44 + pkg/beam-cli/src/apps/registry.rs | 207 +++++ pkg/beam-cli/src/apps/runtime.rs | 45 + pkg/beam-cli/src/apps/store.rs | 147 ++++ pkg/beam-cli/src/apps/validate.rs | 286 +++++++ pkg/beam-cli/src/cli.rs | 77 +- pkg/beam-cli/src/cli/apps.rs | 67 ++ pkg/beam-cli/src/cli/contract.rs | 57 ++ pkg/beam-cli/src/cli/gas.rs | 32 + pkg/beam-cli/src/cli/wallet.rs | 50 ++ pkg/beam-cli/src/commands/apps/execution.rs | 240 ++++++ pkg/beam-cli/src/commands/apps/mod.rs | 356 ++++++++ pkg/beam-cli/src/commands/apps/plans.rs | 77 ++ pkg/beam-cli/src/commands/apps/prompt.rs | 38 + pkg/beam-cli/src/commands/apps/render.rs | 165 ++++ .../src/commands/contract/artifact.rs | 101 +++ pkg/beam-cli/src/commands/contract/error.rs | 140 ++++ pkg/beam-cli/src/commands/contract/export.rs | 273 ++++++ .../src/commands/contract/export/fs_ops.rs | 143 ++++ pkg/beam-cli/src/commands/contract/info.rs | 290 +++++++ pkg/beam-cli/src/commands/contract/mod.rs | 274 ++++++ pkg/beam-cli/src/commands/contract/proxy.rs | 147 ++++ pkg/beam-cli/src/commands/contract/render.rs | 129 +++ pkg/beam-cli/src/commands/contract/source.rs | 85 ++ .../src/commands/contract/sourcify.rs | 223 +++++ pkg/beam-cli/src/commands/contract/target.rs | 249 ++++++ pkg/beam-cli/src/commands/contract/tests.rs | 265 ++++++ .../src/commands/contract/tests/export.rs | 276 ++++++ pkg/beam-cli/src/commands/gas.rs | 290 +++++++ pkg/beam-cli/src/commands/gas/tests.rs | 26 + pkg/beam-cli/src/commands/interactive.rs | 9 +- .../src/commands/interactive_history.rs | 116 +-- .../interactive_history_navigation.rs | 223 +++++ .../interactive_history_navigation_tests.rs | 292 +++++++ .../src/commands/interactive_parse.rs | 17 +- pkg/beam-cli/src/commands/mod.rs | 8 + pkg/beam-cli/src/error.rs | 81 +- pkg/beam-cli/src/evm.rs | 149 ++-- pkg/beam-cli/src/evm/gas.rs | 93 +++ pkg/beam-cli/src/keystore.rs | 14 +- pkg/beam-cli/src/main.rs | 1 + pkg/beam-cli/src/runtime.rs | 19 +- pkg/beam-cli/src/tests.rs | 5 + pkg/beam-cli/src/tests/apps.rs | 236 ++++++ pkg/beam-cli/src/tests/apps_host.rs | 195 +++++ pkg/beam-cli/src/tests/cli_contract.rs | 59 ++ pkg/beam-cli/src/tests/cli_gas.rs | 68 ++ pkg/beam-cli/src/tests/evm.rs | 6 +- pkg/beam-cli/src/tests/evm_gas.rs | 53 ++ pkg/beam-cli/src/tests/interactive.rs | 32 +- .../src/tests/interactive_autocomplete.rs | 103 +-- pkg/beam-cli/src/tests/keystore.rs | 11 +- .../postgres_fixture/docker.rs | 2 +- pkg/sourcify-client-reqwest/Cargo.toml | 19 + pkg/sourcify-client-reqwest/src/client.rs | 245 ++++++ pkg/sourcify-client-reqwest/src/error.rs | 76 ++ pkg/sourcify-client-reqwest/src/lib.rs | 18 + pkg/sourcify-client-reqwest/src/tests/mod.rs | 155 ++++ pkg/sourcify-interface/Cargo.toml | 12 + pkg/sourcify-interface/src/client.rs | 23 + pkg/sourcify-interface/src/contract.rs | 232 ++++++ pkg/sourcify-interface/src/error.rs | 44 + pkg/sourcify-interface/src/lib.rs | 19 + pkg/workspace-hack/Cargo.toml | 4 + pkg/xtask/src/setup/postgres.rs | 2 +- 182 files changed, 17107 insertions(+), 345 deletions(-) create mode 100644 beam-apps/README.md create mode 100644 beam-apps/apps/uniswap/Cargo.lock create mode 100644 beam-apps/apps/uniswap/Cargo.toml create mode 100644 beam-apps/apps/uniswap/README.md create mode 100644 beam-apps/apps/uniswap/assets/icon.svg create mode 100644 beam-apps/apps/uniswap/build.rs create mode 100644 beam-apps/apps/uniswap/manifest.json create mode 100644 beam-apps/apps/uniswap/src/api.rs create mode 100644 beam-apps/apps/uniswap/src/args.rs create mode 100644 beam-apps/apps/uniswap/src/error.rs create mode 100644 beam-apps/apps/uniswap/src/host.rs create mode 100644 beam-apps/apps/uniswap/src/lib.rs create mode 100644 beam-apps/apps/uniswap/src/plan.rs create mode 100644 beam-apps/apps/uniswap/src/tests.rs create mode 100644 beam-apps/fixtures/README.md create mode 100644 beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/icon.svg create mode 100644 beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json create mode 100644 beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json.sig create mode 100644 beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/module.wasm create mode 100644 beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/version.json.sig create mode 100644 beam-apps/fixtures/broad-wildcard/catalog/apps.json create mode 100644 beam-apps/fixtures/broad-wildcard/catalog/apps.json.sig create mode 100644 beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json create mode 100644 beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json.sig create mode 100644 beam-apps/fixtures/broad-wildcard/index.json create mode 100644 beam-apps/fixtures/broad-wildcard/index.json.sig create mode 100644 beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/icon.svg create mode 100644 beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json create mode 100644 beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json.sig create mode 100644 beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/module.wasm create mode 100644 beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/version.json.sig create mode 100644 beam-apps/fixtures/invalid-digest/catalog/apps.json create mode 100644 beam-apps/fixtures/invalid-digest/catalog/apps.json.sig create mode 100644 beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json create mode 100644 beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json.sig create mode 100644 beam-apps/fixtures/invalid-digest/index.json create mode 100644 beam-apps/fixtures/invalid-digest/index.json.sig create mode 100644 beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/icon.svg create mode 100644 beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json create mode 100644 beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json.sig create mode 100644 beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/module.wasm create mode 100644 beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/version.json.sig create mode 100644 beam-apps/fixtures/malformed-permissions/catalog/apps.json create mode 100644 beam-apps/fixtures/malformed-permissions/catalog/apps.json.sig create mode 100644 beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json create mode 100644 beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json.sig create mode 100644 beam-apps/fixtures/malformed-permissions/index.json create mode 100644 beam-apps/fixtures/malformed-permissions/index.json.sig create mode 100644 beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/icon.svg create mode 100644 beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json create mode 100644 beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json.sig create mode 100644 beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/module.wasm create mode 100644 beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/version.json.sig create mode 100644 beam-apps/fixtures/missing-fields/catalog/apps.json create mode 100644 beam-apps/fixtures/missing-fields/catalog/apps.json.sig create mode 100644 beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json create mode 100644 beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json.sig create mode 100644 beam-apps/fixtures/missing-fields/index.json create mode 100644 beam-apps/fixtures/missing-fields/index.json.sig create mode 100644 beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/icon.svg create mode 100644 beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json create mode 100644 beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json.sig create mode 100644 beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/module.wasm create mode 100644 beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/version.json.sig create mode 100644 beam-apps/fixtures/unsupported-beam/catalog/apps.json create mode 100644 beam-apps/fixtures/unsupported-beam/catalog/apps.json.sig create mode 100644 beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json create mode 100644 beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json.sig create mode 100644 beam-apps/fixtures/unsupported-beam/index.json create mode 100644 beam-apps/fixtures/unsupported-beam/index.json.sig create mode 100644 beam-apps/fixtures/valid/apps/uniswap/1.0.0/icon.svg create mode 100644 beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json create mode 100644 beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json.sig create mode 100644 beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm create mode 100644 beam-apps/fixtures/valid/apps/uniswap/1.0.0/version.json.sig create mode 100644 beam-apps/fixtures/valid/catalog/apps.json create mode 100644 beam-apps/fixtures/valid/catalog/apps.json.sig create mode 100644 beam-apps/fixtures/valid/catalog/apps/uniswap.json create mode 100644 beam-apps/fixtures/valid/catalog/apps/uniswap.json.sig create mode 100644 beam-apps/fixtures/valid/index.json create mode 100644 beam-apps/fixtures/valid/index.json.sig create mode 100644 beam-apps/registry-nginx.conf create mode 100644 docker/Dockerfile.beam-app-registry create mode 100644 docs/public/protocol/specs/README.md create mode 100644 docs/public/protocol/specs/privacy-protocol/circuits.mdx create mode 100644 docs/public/protocol/specs/privacy-protocol/contract.mdx create mode 100644 docs/public/protocol/specs/privacy-protocol/data.mdx create mode 100644 docs/public/protocol/specs/privacy-protocol/encryption.mdx create mode 100644 docs/public/protocol/specs/privacy-protocol/lookup.mdx create mode 100644 docs/public/protocol/specs/privacy-protocol/privacy-protocol.mdx create mode 100644 docs/public/protocol/specs/privacy-protocol/security.mdx create mode 100644 docs/public/protocol/specs/privacy-protocol/wallet.mdx create mode 100644 docs/specs/README.md create mode 100644 docs/specs/privacy-protocol/circuits.mdx create mode 100644 docs/specs/privacy-protocol/contract.mdx create mode 100644 docs/specs/privacy-protocol/data.mdx create mode 100644 docs/specs/privacy-protocol/encryption.mdx create mode 100644 docs/specs/privacy-protocol/lookup.mdx create mode 100644 docs/specs/privacy-protocol/privacy-protocol.mdx create mode 100644 docs/specs/privacy-protocol/security.mdx create mode 100644 docs/specs/privacy-protocol/wallet.mdx create mode 100644 pkg/beam-cli/src/apps/approvals.rs create mode 100644 pkg/beam-cli/src/apps/error.rs create mode 100644 pkg/beam-cli/src/apps/host.rs create mode 100644 pkg/beam-cli/src/apps/mod.rs create mode 100644 pkg/beam-cli/src/apps/model.rs create mode 100644 pkg/beam-cli/src/apps/permissions.rs create mode 100644 pkg/beam-cli/src/apps/privacy.rs create mode 100644 pkg/beam-cli/src/apps/registry.rs create mode 100644 pkg/beam-cli/src/apps/runtime.rs create mode 100644 pkg/beam-cli/src/apps/store.rs create mode 100644 pkg/beam-cli/src/apps/validate.rs create mode 100644 pkg/beam-cli/src/cli/apps.rs create mode 100644 pkg/beam-cli/src/cli/contract.rs create mode 100644 pkg/beam-cli/src/cli/gas.rs create mode 100644 pkg/beam-cli/src/cli/wallet.rs create mode 100644 pkg/beam-cli/src/commands/apps/execution.rs create mode 100644 pkg/beam-cli/src/commands/apps/mod.rs create mode 100644 pkg/beam-cli/src/commands/apps/plans.rs create mode 100644 pkg/beam-cli/src/commands/apps/prompt.rs create mode 100644 pkg/beam-cli/src/commands/apps/render.rs create mode 100644 pkg/beam-cli/src/commands/contract/artifact.rs create mode 100644 pkg/beam-cli/src/commands/contract/error.rs create mode 100644 pkg/beam-cli/src/commands/contract/export.rs create mode 100644 pkg/beam-cli/src/commands/contract/export/fs_ops.rs create mode 100644 pkg/beam-cli/src/commands/contract/info.rs create mode 100644 pkg/beam-cli/src/commands/contract/mod.rs create mode 100644 pkg/beam-cli/src/commands/contract/proxy.rs create mode 100644 pkg/beam-cli/src/commands/contract/render.rs create mode 100644 pkg/beam-cli/src/commands/contract/source.rs create mode 100644 pkg/beam-cli/src/commands/contract/sourcify.rs create mode 100644 pkg/beam-cli/src/commands/contract/target.rs create mode 100644 pkg/beam-cli/src/commands/contract/tests.rs create mode 100644 pkg/beam-cli/src/commands/contract/tests/export.rs create mode 100644 pkg/beam-cli/src/commands/gas.rs create mode 100644 pkg/beam-cli/src/commands/gas/tests.rs create mode 100644 pkg/beam-cli/src/commands/interactive_history_navigation.rs create mode 100644 pkg/beam-cli/src/commands/interactive_history_navigation_tests.rs create mode 100644 pkg/beam-cli/src/evm/gas.rs create mode 100644 pkg/beam-cli/src/tests/apps.rs create mode 100644 pkg/beam-cli/src/tests/apps_host.rs create mode 100644 pkg/beam-cli/src/tests/cli_contract.rs create mode 100644 pkg/beam-cli/src/tests/cli_gas.rs create mode 100644 pkg/beam-cli/src/tests/evm_gas.rs create mode 100644 pkg/sourcify-client-reqwest/Cargo.toml create mode 100644 pkg/sourcify-client-reqwest/src/client.rs create mode 100644 pkg/sourcify-client-reqwest/src/error.rs create mode 100644 pkg/sourcify-client-reqwest/src/lib.rs create mode 100644 pkg/sourcify-client-reqwest/src/tests/mod.rs create mode 100644 pkg/sourcify-interface/Cargo.toml create mode 100644 pkg/sourcify-interface/src/client.rs create mode 100644 pkg/sourcify-interface/src/contract.rs create mode 100644 pkg/sourcify-interface/src/error.rs create mode 100644 pkg/sourcify-interface/src/lib.rs diff --git a/.gitignore b/.gitignore index a85fb2f..3bc9f76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ target/ .DS_Store /.direnv/ node_modules +beam-apps/registry-bundle/ +__pycache__/ +*.pyc pkg/**/params dist/ @@ -28,3 +31,4 @@ temp_fixtures # Claude Code completion markers .done +.vercel/ diff --git a/Cargo.lock b/Cargo.lock index 4df7e92..02c9770 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,6 +1613,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "ast_node" version = "5.0.0" @@ -1631,7 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ "concurrent-queue", - "event-listener", + "event-listener 2.5.3", "futures-core", ] @@ -1666,6 +1676,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f" +dependencies = [ + "async-lock", + "event-listener 5.4.1", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -2052,7 +2083,7 @@ checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "beam-cli" -version = "0.1.1" +version = "0.2.0" dependencies = [ "argon2", "async-trait", @@ -2085,9 +2116,13 @@ dependencies = [ "serial_test", "sha2", "shlex", + "sourcify-client-reqwest", + "sourcify-interface", "tempfile", "thiserror 1.0.69", "tokio", + "url", + "wasmi", "web3", "workspace-hack", ] @@ -4342,6 +4377,27 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -5064,13 +5120,28 @@ checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" dependencies = [ "base64 0.21.7", "bytes", - "headers-core", + "headers-core 0.2.0", "http 0.2.12", "httpdate", "mime", "sha1 0.10.6", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core 0.3.0", + "http 1.4.0", + "httpdate", + "mime", + "sha1 0.10.6", +] + [[package]] name = "headers-core" version = "0.2.0" @@ -5080,6 +5151,15 @@ dependencies = [ "http 0.2.12", ] +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", +] + [[package]] name = "heck" version = "0.4.1" @@ -5294,6 +5374,40 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "httpmock" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4888a4d02d8e1f92ffb6b4965cf5ff56dda36ef41975f41c6fa0f6bde78c4e" +dependencies = [ + "assert-json-diff 2.0.2", + "async-object-pool", + "async-trait", + "base64 0.22.1", + "bytes", + "crossbeam-utils", + "form_urlencoded", + "futures-timer", + "futures-util", + "headers 0.4.1", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "path-tree", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "stringmetrics", + "tabwriter", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", +] + [[package]] name = "hybrid-array" version = "0.4.11" @@ -6801,7 +6915,7 @@ version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3ae325bcceb48a24302ac57e1055f9173f5fd53be535603ea0ed41dea92db5" dependencies = [ - "assert-json-diff", + "assert-json-diff 1.1.0", "colored", "difference", "httparse", @@ -8065,6 +8179,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "path-tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136" +dependencies = [ + "smallvec", +] + [[package]] name = "payy-evm-client" version = "0.1.0" @@ -10674,6 +10797,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -11201,6 +11334,35 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bceb57dc07c92cdae60f5b27b3fa92ecaaa42fe36c55e22dbfb0b44893e0b1f7" +[[package]] +name = "sourcify-client-reqwest" +version = "0.1.0" +dependencies = [ + "async-trait", + "contextful", + "futures-util", + "httpmock", + "reqwest 0.12.28", + "serde_json", + "sourcify-interface", + "thiserror 1.0.69", + "tokio", + "url", + "workspace-hack", +] + +[[package]] +name = "sourcify-interface" +version = "0.1.0" +dependencies = [ + "async-trait", + "contextful", + "serde", + "serde_json", + "thiserror 1.0.69", + "workspace-hack", +] + [[package]] name = "spin" version = "0.5.2" @@ -11248,6 +11410,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "string_enum" version = "1.0.2" @@ -11259,6 +11431,12 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "stringmetrics" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765" + [[package]] name = "stringprep" version = "0.1.5" @@ -11624,6 +11802,15 @@ dependencies = [ "libc", ] +[[package]] +name = "tabwriter" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "tap" version = "1.0.1" @@ -12983,7 +13170,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +dependencies = [ + "leb128fmt", + "wasmparser 0.248.0", ] [[package]] @@ -12994,8 +13191,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", ] [[package]] @@ -13026,6 +13223,57 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmi" +version = "2.0.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45c55fe72d4967863199560df94b78b255cd5da14f82948c45635cb2f404796c" +dependencies = [ + "spin 0.9.8", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.228.0", + "wat", +] + +[[package]] +name = "wasmi_collections" +version = "2.0.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481e5b8f8080e86546c85a29244886579af9dc59fd28fa1f6ce5cfa1cd734221" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "2.0.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15765bc7b07c3ca8fede7335623e8d8c156abe804979621cd28a5a7f560300eb" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "2.0.0-beta.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb152ed4c67d8fb478464d62328c9daa7185b343039bdc44d4139beb5396f09" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.10.0", + "indexmap 2.14.0", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -13038,6 +13286,16 @@ dependencies = [ "semver 1.0.27", ] +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags 2.10.0", + "indexmap 2.14.0", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -13052,6 +13310,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wast" +version = "248.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width 0.2.2", + "wasm-encoder 0.248.0", +] + +[[package]] +name = "wat" +version = "1.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" +dependencies = [ + "wast", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -13086,7 +13366,7 @@ dependencies = [ "ethereum-types", "futures", "futures-timer", - "headers", + "headers 0.3.9", "hex", "idna 0.4.0", "jsonrpc-core", @@ -13676,9 +13956,9 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", + "wasmparser 0.244.0", "wit-parser", ] @@ -13697,7 +13977,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] @@ -13813,6 +14093,7 @@ dependencies = [ "lalrpop-util", "lazy_static", "libc", + "libm", "libz-sys", "linux-raw-sys 0.11.0", "linux-raw-sys 0.4.15", @@ -13884,6 +14165,7 @@ dependencies = [ "smallvec", "socket2 0.5.10", "socket2 0.6.1", + "spin 0.9.8", "strum 0.27.2", "subtle", "syn 1.0.109", diff --git a/Cargo.toml b/Cargo.toml index 71dd16e..b97a6d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,8 @@ support-rpc = { path = "./pkg/support-rpc" } support-storage-interface = { path = "./pkg/support-storage-interface" } support-storage-pg = { path = "./pkg/support-storage-pg" } support = { path = "./pkg/support" } +sourcify-client-reqwest = { path = "./pkg/sourcify-client-reqwest" } +sourcify-interface = { path = "./pkg/sourcify-interface" } p2p = { path = "./pkg/p2p" } p2p2 = { path = "./pkg/p2p2" } parse-link = { path = "./pkg/parse-link" } @@ -318,6 +320,7 @@ unimock = "0.6.8" secp256k1 = { version = "0.28.0", features = ["rand", "global-context", "recovery"] } url = { version = "2.5.8" } semver = "1.0.15" +wasmi = "2.0.0-beta.2" shlex = "1.3.0" sha1 = "0.10.1" sha2 = "0.10.6" diff --git a/README.md b/README.md index 91ca446..3f4562a 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Install/run postgres and create a db called `guild`. docker (recommended): ```bash -docker run -it --rm -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB=guild -e POSTGRES_USER=$USER -p 5432:5432 postgres:18 +docker run -it --rm -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_DB=guild -e POSTGRES_USER=$USER -p 5432:5432 postgres:17 ``` macos: diff --git a/app/tsconfig.json b/app/tsconfig.json index ded86e1..e93933c 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -35,6 +35,7 @@ "**/*.tsx" ], "exclude": [ + "packages/beam-site", "packages/link", "packages/network", "packages/privacy-vault", diff --git a/app/yarn.lock b/app/yarn.lock index b5696ea..4ef43da 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -7915,6 +7915,13 @@ dependencies: "@types/node" "*" +"@types/debug@^4.0.0": + version "4.1.13" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.13.tgz#22d1cc9d542d3593caea764f974306ab36286ee7" + integrity sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw== + dependencies: + "@types/ms" "*" + "@types/debug@^4.1.7": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -7954,6 +7961,13 @@ resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.46.tgz#381daaca1360ff8a7c8dff63f32e69745b9fb1e1" integrity sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw== +"@types/hast@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" @@ -8025,6 +8039,18 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.24.tgz#4ae334fc62c0e915ca8ed8e35dcc6d4eeb29215f" integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ== +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + +"@types/mdx@^2.0.13": + version "2.0.13" + resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.13.tgz#68f6877043d377092890ff5b298152b0a21671bd" + integrity sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw== + "@types/ms@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" @@ -8141,6 +8167,11 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + "@types/use-sync-external-store@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" @@ -8427,6 +8458,11 @@ "@uiw/codemirror-extensions-basic-setup" "4.25.3" codemirror "^6.0.0" +"@ungap/structured-clone@^1.0.0": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz#0e8f34854df7966b09304a18e808b23997bb9fc1" + integrity sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ== + "@ungap/structured-clone@^1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" @@ -9808,6 +9844,11 @@ badgin@^1.1.5: resolved "https://registry.yarnpkg.com/badgin/-/badgin-1.2.3.tgz#994b5f519827d7d5422224825b2c8faea2bc43ad" integrity sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw== +bail@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d" + integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -10203,6 +10244,11 @@ caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001774: preact "^10.16.0" sha.js "^2.4.11" +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + chai@^5.2.0: version "5.3.3" resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" @@ -10254,6 +10300,21 @@ char-regex@^2.0.0: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.2.tgz#81385bb071af4df774bff8721d0ca15ef29ea0bb" integrity sha512-cbGOjAptfM2LVmWhwRFHEKTPkLwNddVmuqYZQt895yXwAsWsXObCG+YN4DGQ/JBtT4GP1a1lPPdio2z413LmTg== +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + charenc@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" @@ -10473,6 +10534,11 @@ comlink@^4.4.1: resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.2.tgz#cbbcd82742fbebc06489c28a183eedc5c60a2bca" integrity sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g== +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + commander@14.0.1: version "14.0.1" resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.1.tgz#2f9225c19e6ebd0dc4404dd45821b2caa17ea09b" @@ -10901,7 +10967,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@~4.4.1: +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.1, debug@~4.4.1: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -10932,6 +10998,13 @@ decimal.js@^10.4.2, decimal.js@^10.4.3: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== +decode-named-character-reference@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz#3e40603760874c2e5867691b599d73a7da25b53f" + integrity sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q== + dependencies: + character-entities "^2.0.0" + decode-uri-component@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" @@ -11053,7 +11126,7 @@ depd@2.0.0, depd@^2.0.0: resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== -dequal@^2.0.3: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -11098,6 +11171,13 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== +devlop@^1.0.0, devlop@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + didyoumean@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" @@ -11633,6 +11713,11 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== +escape-string-regexp@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" + integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== + escodegen@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" @@ -12563,6 +12648,18 @@ express@^5.0.1: type-is "^2.0.1" vary "^1.1.2" +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== + dependencies: + is-extendable "^0.1.0" + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + extension-port-stream@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/extension-port-stream/-/extension-port-stream-3.0.0.tgz#00a7185fe2322708a36ed24843c81bd754925fef" @@ -13113,6 +13210,11 @@ getenv@^2.0.0: resolved "https://registry.yarnpkg.com/getenv/-/getenv-2.0.0.tgz#b1698c7b0f29588f4577d06c42c73a5b475c69e0" integrity sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ== +github-slugger@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a" + integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== + glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -13251,6 +13353,16 @@ graphql@^15.4.0: resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.10.1.tgz#e9ff3bb928749275477f748b14aa5c30dcad6f2f" integrity sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg== +gray-matter@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.3.tgz#e893c064825de73ea1f5f7d88c7a9f7274288798" + integrity sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q== + dependencies: + js-yaml "^3.13.1" + kind-of "^6.0.2" + section-matter "^1.0.0" + strip-bom-string "^1.0.0" + h3@^1.15.5: version "1.15.5" resolved "https://registry.yarnpkg.com/h3/-/h3-1.15.5.tgz#e2f28d4a66a249973bb050eaddb06b9ab55506f8" @@ -13334,6 +13446,51 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hast-util-heading-rank@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz#2d5c6f2807a7af5c45f74e623498dd6054d2aba8" + integrity sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-is-element@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz#6e31a6532c217e5b533848c7e52c9d9369ca0932" + integrity sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-to-html@^9.0.0: + version "9.0.5" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" + integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + +hast-util-to-string@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz#a4f15e682849326dd211c97129c94b0c3e76527c" + integrity sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + hermes-estree@0.23.1: version "0.23.1" resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.23.1.tgz#d0bac369a030188120ee7024926aabe5a9f84fdb" @@ -13429,6 +13586,11 @@ html-parse-stringify@^3.0.1: dependencies: void-elements "3.1.0" +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + http-errors@2.0.0, http-errors@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -13770,6 +13932,11 @@ is-docker@^2.0.0, is-docker@^2.1.1: resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== +is-extendable@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -13872,6 +14039,11 @@ is-plain-obj@^2.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== +is-plain-obj@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -15175,7 +15347,7 @@ keyvaluestorage-interface@^1.0.0: resolved "https://registry.yarnpkg.com/keyvaluestorage-interface/-/keyvaluestorage-interface-1.0.0.tgz#13ebdf71f5284ad54be94bd1ad9ed79adad515ff" integrity sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g== -kind-of@^6.0.2: +kind-of@^6.0.0, kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -15474,6 +15646,11 @@ long@^5.0.0: resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== +longest-streak@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" + integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== + loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -15572,6 +15749,11 @@ markdown-it@^10.0.0: mdurl "^1.0.1" uc.micro "^1.0.5" +markdown-table@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a" + integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== + marky@^1.2.2: version "1.3.0" resolved "https://registry.yarnpkg.com/marky/-/marky-1.3.0.tgz#422b63b0baf65022f02eda61a238eccdbbc14997" @@ -15591,6 +15773,144 @@ md5@^2.3.0: crypt "0.0.2" is-buffer "~1.1.6" +mdast-util-find-and-replace@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df" + integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg== + dependencies: + "@types/mdast" "^4.0.0" + escape-string-regexp "^5.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +mdast-util-from-markdown@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz#c95822b91aab75f18a4cbe8b2f51b873ed2cf0c7" + integrity sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-gfm-autolink-literal@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz#abd557630337bd30a6d5a4bd8252e1c2dc0875d5" + integrity sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ== + dependencies: + "@types/mdast" "^4.0.0" + ccount "^2.0.0" + devlop "^1.0.0" + mdast-util-find-and-replace "^3.0.0" + micromark-util-character "^2.0.0" + +mdast-util-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz#7778e9d9ca3df7238cc2bd3fa2b1bf6a65b19403" + integrity sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + +mdast-util-gfm-strikethrough@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16" + integrity sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm-table@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38" + integrity sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + markdown-table "^3.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm-task-list-item@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936" + integrity sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ== + dependencies: + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-gfm@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz#2cdf63b92c2a331406b0fb0db4c077c1b0331751" + integrity sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ== + dependencies: + mdast-util-from-markdown "^2.0.0" + mdast-util-gfm-autolink-literal "^2.0.0" + mdast-util-gfm-footnote "^2.0.0" + mdast-util-gfm-strikethrough "^2.0.0" + mdast-util-gfm-table "^2.0.0" + mdast-util-gfm-task-list-item "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-phrasing@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3" + integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w== + dependencies: + "@types/mdast" "^4.0.0" + unist-util-is "^6.0.0" + +mdast-util-to-hast@^13.0.0: + version "13.2.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz#d7ff84ca499a57e2c060ae67548ad950e689a053" + integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +mdast-util-to-markdown@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b" + integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-string "^4.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-decode-string "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" + +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== + dependencies: + "@types/mdast" "^4.0.0" + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -16239,6 +16559,279 @@ micro-ftch@^0.3.1: resolved "https://registry.yarnpkg.com/micro-ftch/-/micro-ftch-0.3.1.tgz#6cb83388de4c1f279a034fb0cf96dfc050853c5f" integrity sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg== +micromark-core-commonmark@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4" + integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg== + dependencies: + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-autolink-literal@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz#6286aee9686c4462c1e3552a9d505feddceeb935" + integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-footnote@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz#4dab56d4e398b9853f6fe4efac4fc9361f3e0750" + integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw== + dependencies: + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-strikethrough@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz#86106df8b3a692b5f6a92280d3879be6be46d923" + integrity sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-table@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz#fac70bcbf51fe65f5f44033118d39be8a9b5940b" + integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-tagfilter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57" + integrity sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-extension-gfm-task-list-item@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz#bcc34d805639829990ec175c3eea12bb5b781f2c" + integrity sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b" + integrity sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w== + dependencies: + micromark-extension-gfm-autolink-literal "^2.0.0" + micromark-extension-gfm-footnote "^2.0.0" + micromark-extension-gfm-strikethrough "^2.0.0" + micromark-extension-gfm-table "^2.0.0" + micromark-extension-gfm-tagfilter "^2.0.0" + micromark-extension-gfm-task-list-item "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639" + integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-label@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1" + integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg== + dependencies: + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-space@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc" + integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-title@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94" + integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-whitespace@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1" + integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-chunked@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051" + integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-classify-character@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629" + integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-combine-extensions@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9" + integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5" + integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-decode-string@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2" + integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-html-tag-name@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825" + integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== + +micromark-util-normalize-identifier@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d" + integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-resolve-all@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b" + integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-subtokenize@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee" + integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +micromark@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb" + integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" @@ -17490,6 +18083,11 @@ prop-types@15.x, prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2, pr object-assign "^4.1.1" react-is "^16.13.1" +property-information@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" + integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== + protobufjs@^7.2.5: version "7.5.4" resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" @@ -18402,6 +19000,80 @@ regjsparser@^0.13.0: dependencies: jsesc "~3.1.0" +rehype-autolink-headings@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz#531087e155d9df053944923efd47d99728f3b196" + integrity sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw== + dependencies: + "@types/hast" "^3.0.0" + "@ungap/structured-clone" "^1.0.0" + hast-util-heading-rank "^3.0.0" + hast-util-is-element "^3.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + +rehype-slug@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/rehype-slug/-/rehype-slug-6.0.0.tgz#1d21cf7fc8a83ef874d873c15e6adaee6344eaf1" + integrity sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A== + dependencies: + "@types/hast" "^3.0.0" + github-slugger "^2.0.0" + hast-util-heading-rank "^3.0.0" + hast-util-to-string "^3.0.0" + unist-util-visit "^5.0.0" + +rehype-stringify@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/rehype-stringify/-/rehype-stringify-10.0.1.tgz#2ec1ebc56c6aba07905d3b4470bdf0f684f30b75" + integrity sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA== + dependencies: + "@types/hast" "^3.0.0" + hast-util-to-html "^9.0.0" + unified "^11.0.0" + +remark-gfm@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b" + integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-gfm "^3.0.0" + micromark-extension-gfm "^3.0.0" + remark-parse "^11.0.0" + remark-stringify "^11.0.0" + unified "^11.0.0" + +remark-parse@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1" + integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + micromark-util-types "^2.0.0" + unified "^11.0.0" + +remark-rehype@^11.1.1: + version "11.1.2" + resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.2.tgz#2addaadda80ca9bd9aa0da763e74d16327683b37" + integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + mdast-util-to-hast "^13.0.0" + unified "^11.0.0" + vfile "^6.0.0" + +remark-stringify@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3" + integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-to-markdown "^2.0.0" + unified "^11.0.0" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -18720,6 +19392,14 @@ secp256k1@^5.0.0: node-addon-api "^5.0.0" node-gyp-build "^4.2.0" +section-matter@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/section-matter/-/section-matter-1.0.0.tgz#e9041953506780ec01d59f292a19c7b850b84167" + integrity sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA== + dependencies: + extend-shallow "^2.0.1" + kind-of "^6.0.0" + semver@7.7.2: version "7.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" @@ -19127,6 +19807,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + split-on-first@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f" @@ -19411,6 +20096,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -19439,6 +20132,11 @@ strip-ansi@^7.0.1: dependencies: ansi-regex "^6.0.1" +strip-bom-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" + integrity sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g== + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -19813,6 +20511,16 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +trough@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" + integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== + ts-api-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" @@ -20136,6 +20844,19 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz#301d4f8a43d2b75c97adfad87c9dd5350c9475d1" integrity sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ== +unified@^11.0.0, unified@^11.0.5: + version "11.0.5" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" + integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== + dependencies: + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" + extend "^3.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" + unimodules-app-loader@~6.0.7: version "6.0.7" resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-6.0.7.tgz#d88db74075815bcdc088c6c6823a2b08394a1225" @@ -20148,6 +20869,44 @@ unique-string@~2.0.0: dependencies: crypto-random-string "^2.0.0" +unist-util-is@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.1.tgz#d0a3f86f2dd0db7acd7d8c2478080b5c67f9c6a9" + integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz#777df7fb98652ce16b4b7cd999d0a1a40efa3a02" + integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz#9a2a28b0aa76a15e0da70a08a5863a2f060e2468" + integrity sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + universal-user-agent@^7.0.0, universal-user-agent@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.3.tgz#c05870a58125a2dc00431f2df815a77fe69736be" @@ -20356,6 +21115,22 @@ vary@^1, vary@^1.1.2, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== +vfile-message@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.3.tgz#87b44dddd7b70f0641c2e3ed0864ba73e2ea8df4" + integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + viem@2.23.2: version "2.23.2" resolved "https://registry.yarnpkg.com/viem/-/viem-2.23.2.tgz#db395c8cf5f4fb5572914b962fb8ce5db09f681c" @@ -20918,3 +21693,8 @@ zustand@^5.0.1: version "5.0.11" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494" integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg== + +zwitch@^2.0.0, zwitch@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/beam-apps/README.md b/beam-apps/README.md new file mode 100644 index 0000000..d1e3f7d --- /dev/null +++ b/beam-apps/README.md @@ -0,0 +1,71 @@ +# Beam Apps + +Beam apps are Payy-controlled extension packages for `beam-cli`. + +Source files under `beam-apps/apps/` are the source of truth. CI builds those +sources into static release artifacts under `beam-apps/registry-bundle/`, signs +the registry metadata, verifies the bundle, and then bakes the generated files +into the `beam-app-registry` Docker image. The registry bundle is generated +release output and is not checked into git. + +The generated bundle serves two surfaces: + +- Install artifacts consumed by Beam CLI: `index.json`, signed manifests, WASM + modules, icon assets, and digest/signature metadata. +- Catalog data consumed by the Beam website: `catalog/apps.json` and + `catalog/apps/.json`, including display metadata, structured command + docs, permission summaries, icon metadata, and README markdown. + +The live registry is immutable static content for each image. Rollback is a +Kubernetes image rollback, not an in-place mutation of served registry files. + +Local bundle build: + +```bash +scripts/beam-app-registry/build.py +scripts/beam-app-registry/verify.py +``` + +Local registry server: + +```bash +scripts/beam-app-registry/run-local.py +``` + +In another shell: + +```bash +export BEAM_APP_REGISTRY_URL=http://127.0.0.1:8787 +export BEAM_HOME="$(mktemp -d)" +cargo run -p beam-cli --bin beam -- apps install uniswap --dry-run +``` + +Set `BEAM_UNISWAP_PUBLIC_API_KEY` before starting `run-local.py` when testing +real Uniswap Trading API access. If it is unset, the local WASM embeds an empty +key, which is enough for registry install and permission testing. + +Deployed registry smoke test: + +```bash +scripts/beam-app-registry/smoke.py --base-url https://registry.beam.payy.network +``` + +Mainnet registry releases run through +`.github/workflows/beam-app-registry.release.mainnet.yml`. The workflow builds +the signed bundle, deploys the `beam-app-registry` Helm release, publishes the +`registry.beam.payy.network` Cloudflare `A` record to the GKE ingress IP, waits +for DNS plus the GKE `ManagedCertificate`, then runs the deployed smoke test. +The `CLOUDFLARE_API_TOKEN` GitHub secret must be allowed to edit DNS records in +the `payy.network` zone. + +App source stays separate from Beam core. Product apps live under +`beam-apps/apps/` and must not path-depend on `pkg/*` crates or inherit root +workspace dependencies. The Uniswap app is its own Rust workspace; CI installs +`wasm32-unknown-unknown`, injects the Payy-managed public Uniswap API key from +the `BEAM_UNISWAP_PUBLIC_API_KEY` GitHub secret, builds its release WASM, +verifies the generated registry bundle, and bakes only the signed static bundle +into the registry image. + +Until a shared app SDK crate is published, product apps may vendor app-local host +ABI structs. Beam CLI remains the generic host/runtime and must not contain +product-specific app business logic. diff --git a/beam-apps/apps/uniswap/Cargo.lock b/beam-apps/apps/uniswap/Cargo.lock new file mode 100644 index 0000000..5f54eed --- /dev/null +++ b/beam-apps/apps/uniswap/Cargo.lock @@ -0,0 +1,255 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "beam-app-uniswap" +version = "1.0.0" +dependencies = [ + "hex", + "num-bigint", + "num-traits", + "serde", + "serde_json", + "sha2", + "thiserror", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/beam-apps/apps/uniswap/Cargo.toml b/beam-apps/apps/uniswap/Cargo.toml new file mode 100644 index 0000000..7b710f6 --- /dev/null +++ b/beam-apps/apps/uniswap/Cargo.toml @@ -0,0 +1,19 @@ +[workspace] + +[package] +name = "beam-app-uniswap" +version = "1.0.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +hex = "0.4" +num-bigint = "0.4" +num-traits = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" diff --git a/beam-apps/apps/uniswap/README.md b/beam-apps/apps/uniswap/README.md new file mode 100644 index 0000000..0206a19 --- /dev/null +++ b/beam-apps/apps/uniswap/README.md @@ -0,0 +1,84 @@ +# Uniswap App + +The Uniswap app turns a swap request into a Beam-approved action plan. It asks +the Uniswap Trading API for a quote, checks your current ERC-20 allowance, +prepares an exact approval only when one is needed, builds the swap transaction, +and hands the whole plan to Beam for approval and execution. + +Beam owns your wallet boundary. The app never receives private keys, cannot sign +transactions, and cannot send a transaction on its own. + +## Install + +```bash +beam apps install uniswap +``` + +Beam shows the publisher, version, supported chains, network access, wallet +capabilities, and storage permissions before activating the app. To inspect the +same permission summary without installing: + +```bash +beam apps install uniswap --dry-run +``` + +## Swap + +```bash +beam x uniswap swap [options] +``` + +Example: + +```bash +beam x uniswap swap USDC ETH 100 --chain base --from alice +``` + +Beam shows the quote, any required approval, and the swap as a single plan. You +approve the final plan before Beam signs or submits anything. + +## Options + +- `--min-receive ` sets the minimum acceptable output amount. +- `--max-gas ` rejects the plan if estimated gas exceeds the limit. +- `--slippage-bps ` sets max slippage in basis points. +- `--recipient ` sends output to another wallet, ENS name, or + EVM address. +- `--deadline-seconds ` sets the quote and transaction deadline window. +- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval + instead of the default exact approval. + +## How a Swap Works + +1. The app fetches a quote through Beam-mediated HTTPS access. +2. Beam reads your token balance and allowance through its chain APIs. +3. If allowance is short, Beam adds an exact ERC-20 approval step. +4. The app prepares the swap transaction. +5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the + receipt. + +## Agents + +Agents and other non-interactive callers should prepare a continuation, inspect +it, then explicitly approve and execute it: + +```bash +beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json +beam apps approvals show +beam apps approvals approve --execute +``` + +`--no-prompt` fails closed for wallet-affecting swaps unless the command is +preparing a continuation or executing an already-approved continuation. + +## Permissions + +The app requests HTTPS access to the Uniswap Trading API, read/simulate/send +access on supported public EVM chains, ERC-20 approval planning, wallet balance +reads, transaction proposals, and app-local storage. It does not request Beam +privacy capabilities in v1. + +Supported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia. + +Approvals default to the exact amount required. Unlimited approvals require the +explicit `--unlimited-approval` flag and are shown in the Beam approval prompt. diff --git a/beam-apps/apps/uniswap/assets/icon.svg b/beam-apps/apps/uniswap/assets/icon.svg new file mode 100644 index 0000000..bbe75a0 --- /dev/null +++ b/beam-apps/apps/uniswap/assets/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/beam-apps/apps/uniswap/build.rs b/beam-apps/apps/uniswap/build.rs new file mode 100644 index 0000000..7429b0d --- /dev/null +++ b/beam-apps/apps/uniswap/build.rs @@ -0,0 +1,18 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-env-changed=BEAM_UNISWAP_PUBLIC_API_KEY"); + + let key = match env::var("BEAM_UNISWAP_PUBLIC_API_KEY") { + Ok(value) => value, + Err(env::VarError::NotPresent) => String::new(), + Err(err) => return Err(Box::new(err)), + }; + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + let generated = format!("pub const BEAM_UNISWAP_PUBLIC_API_KEY: &str = {:?};\n", key); + + fs::write(out_dir.join("public_api_key.rs"), generated)?; + Ok(()) +} diff --git a/beam-apps/apps/uniswap/manifest.json b/beam-apps/apps/uniswap/manifest.json new file mode 100644 index 0000000..cff6f6e --- /dev/null +++ b/beam-apps/apps/uniswap/manifest.json @@ -0,0 +1,234 @@ +{ + "format_version": 1, + "id": "uniswap", + "display_name": "Uniswap", + "version": "1.0.0", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "min_beam_version": "0.1.2", + "wasm": { + "sha256": "", + "entrypoint": "beam_app_main" + }, + "icon": { + "path": "assets/icon.svg", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + }, + "catalog": { + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "sensitive_args": [], + "input_schema": { + "type": "object", + "required": ["sell_token", "buy_token", "amount"], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": ["state", "steps"], + "properties": { + "state": { + "enum": ["prepared", "pending", "confirmed", "dropped"] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + } + } + ], + "permissions": { + "http": [ + { + "url": "https://trade-api.gateway.uniswap.org/v1/*" + } + ], + "chains": [ + { + "chain": "ethereum", + "operations": ["read", "simulate", "send-transaction", "erc20-approval"], + "selectors": ["0x095ea7b3", "*"] + }, + { + "chain": "base", + "operations": ["read", "simulate", "send-transaction", "erc20-approval"], + "selectors": ["0x095ea7b3", "*"] + }, + { + "chain": "polygon", + "operations": ["read", "simulate", "send-transaction", "erc20-approval"], + "selectors": ["0x095ea7b3", "*"] + }, + { + "chain": "bnb", + "operations": ["read", "simulate", "send-transaction", "erc20-approval"], + "selectors": ["0x095ea7b3", "*"] + }, + { + "chain": "arbitrum", + "operations": ["read", "simulate", "send-transaction", "erc20-approval"], + "selectors": ["0x095ea7b3", "*"] + }, + { + "chain": "sepolia", + "operations": ["read", "simulate", "send-transaction", "erc20-approval"], + "selectors": ["0x095ea7b3", "*"] + } + ], + "wallet": { + "read_balances": true, + "propose_transactions": true, + "erc20_approval": true + }, + "storage": { + "app_local": true + }, + "privacy": [] + }, + "host_api": { + "privacy_reserved": true + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "" + } +} diff --git a/beam-apps/apps/uniswap/src/api.rs b/beam-apps/apps/uniswap/src/api.rs new file mode 100644 index 0000000..c8f66cf --- /dev/null +++ b/beam-apps/apps/uniswap/src/api.rs @@ -0,0 +1,174 @@ +use serde_json::{Value, json}; + +use crate::{Error, Result}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApprovalResponse { + pub transaction: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuoteResponse { + pub amount_out: String, + pub minimum_amount_out: Option, + pub quote: Value, + pub quote_id: String, + pub route: String, + pub valid_for_seconds: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SwapResponse { + pub raw: Value, + pub transaction: UniswapTransaction, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct UniswapTransaction { + pub data: String, + pub gas_limit: Option, + pub gas_price: Option, + pub to: String, + pub value: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuoteRequest { + pub amount: String, + pub chain_id: u64, + pub recipient: String, + pub slippage_bps: u32, + pub token_in: String, + pub token_out: String, + pub wallet: String, +} + +pub fn check_approval_payload(request: &QuoteRequest) -> Value { + json!({ + "amount": request.amount, + "chainId": request.chain_id, + "token": request.token_in, + "walletAddress": request.wallet, + }) +} + +pub fn quote_payload(request: &QuoteRequest) -> Value { + json!({ + "amount": request.amount, + "protocols": ["V2", "V3", "V4"], + "recipient": request.recipient, + "slippageTolerance": request.slippage_bps, + "tokenIn": request.token_in, + "tokenInChainId": request.chain_id, + "tokenOut": request.token_out, + "tokenOutChainId": request.chain_id, + "type": "EXACT_INPUT", + "walletAddress": request.wallet, + }) +} + +pub fn swap_payload(quote: &QuoteResponse, wallet: &str) -> Value { + json!({ + "quote": quote.quote, + "simulateTransaction": true, + "walletAddress": wallet, + }) +} + +pub fn parse_quote(value: Value, request: &QuoteRequest) -> Result { + validate_optional_field( + &value, + &["tokenInChainId", "chainId"], + &request.chain_id.to_string(), + )?; + validate_optional_field(&value, &["tokenOutChainId"], &request.chain_id.to_string())?; + validate_optional_field(&value, &["tokenIn", "inputToken"], &request.token_in)?; + validate_optional_field(&value, &["tokenOut", "outputToken"], &request.token_out)?; + let amount_out = + first_string(&value, &["amountOut", "output", "quoteAmount"]).ok_or_else(|| { + Error::InvalidUniswapResponse { + reason: "quote missing output amount".to_string(), + } + })?; + let quote_id = first_string(&value, &["quoteId", "requestId", "routingId"]) + .unwrap_or_else(|| "uniswap-quote".to_string()); + let route = first_string(&value, &["routing", "routeString", "route"]) + .unwrap_or_else(|| "classic".to_string()); + if route.to_ascii_lowercase().contains("dutch") + || route.to_ascii_lowercase().contains("uniswapx") + { + return Err(Error::UnsupportedUniswapRoute { route }); + } + + Ok(QuoteResponse { + amount_out, + minimum_amount_out: first_string( + &value, + &["amountOutMinimum", "minimumAmountOut", "minAmountOut"], + ), + quote: value, + quote_id, + route, + valid_for_seconds: 180, + }) +} + +pub fn find_transaction(value: &Value) -> Option { + [ + "approval", + "approvalTransaction", + "swap", + "transaction", + "tx", + ] + .iter() + .find_map(|key| value.get(key)) + .or(Some(value)) + .and_then(parse_transaction) +} + +pub fn selector(data: &str) -> Option { + let data = data.strip_prefix("0x").unwrap_or(data); + (data.len() >= 8).then(|| format!("0x{}", &data[..8])) +} + +pub fn approval_spender(data: &str) -> Option { + let data = data.strip_prefix("0x").unwrap_or(data); + if data.len() < 8 + 64 || &data[..8].to_ascii_lowercase() != "095ea7b3" { + return None; + } + Some(format!("0x{}", &data[8 + 24..8 + 64])) +} + +fn validate_optional_field(value: &Value, keys: &[&str], expected: &str) -> Result<()> { + let Some(actual) = first_string(value, keys) else { + return Ok(()); + }; + if !actual.eq_ignore_ascii_case(expected) { + return Err(Error::InvalidUniswapResponse { + reason: format!("quote field mismatch: expected {expected}, got {actual}"), + }); + } + + Ok(()) +} + +fn parse_transaction(value: &Value) -> Option { + Some(UniswapTransaction { + data: first_string(value, &["data", "calldata", "input"])?, + gas_limit: first_string(value, &["gasLimit", "gas"]), + gas_price: first_string(value, &["gasPrice", "maxFeePerGas"]), + to: first_string(value, &["to", "target"])?, + value: first_string(value, &["value"]).unwrap_or_else(|| "0".to_string()), + }) +} + +fn first_string(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + value.get(key).and_then(|value| match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }) + }) +} diff --git a/beam-apps/apps/uniswap/src/args.rs b/beam-apps/apps/uniswap/src/args.rs new file mode 100644 index 0000000..a1cef25 --- /dev/null +++ b/beam-apps/apps/uniswap/src/args.rs @@ -0,0 +1,109 @@ +use crate::{Error, Result}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SwapArgs { + pub amount: String, + pub buy_token: String, + pub deadline_seconds: u64, + pub max_gas: Option, + pub min_receive: Option, + pub recipient: Option, + pub sell_token: String, + pub slippage_bps: u32, + pub unlimited_approval: bool, +} + +impl SwapArgs { + pub fn parse(args: &[String]) -> Result { + if args.len() < 4 || args.first().map(String::as_str) != Some("swap") { + return Err(Error::UnsupportedCommand { + command: "swap requires sell token, buy token, and amount".to_string(), + }); + } + + let mut output = Self { + amount: args[3].clone(), + buy_token: args[2].clone(), + deadline_seconds: 20 * 60, + max_gas: None, + min_receive: None, + recipient: None, + sell_token: args[1].clone(), + slippage_bps: 50, + unlimited_approval: false, + }; + + let mut index = 4; + while index < args.len() { + match args[index].as_str() { + "--deadline-seconds" => { + output.deadline_seconds = parse_next(args, &mut index, "--deadline-seconds")? + .parse::() + .map_err(|_| Error::InvalidArgument { + reason: "invalid deadline seconds".to_string(), + })?; + } + "--max-gas" => output.max_gas = Some(parse_next(args, &mut index, "--max-gas")?), + "--min-receive" => { + output.min_receive = Some(parse_next(args, &mut index, "--min-receive")?) + } + "--recipient" => { + output.recipient = Some(parse_next(args, &mut index, "--recipient")?) + } + "--slippage" => { + output.slippage_bps = + parse_slippage_bps(&parse_next(args, &mut index, "--slippage")?)?; + } + "--slippage-bps" => { + output.slippage_bps = parse_next(args, &mut index, "--slippage-bps")? + .parse::() + .map_err(|_| Error::InvalidArgument { + reason: "invalid slippage bps".to_string(), + })?; + } + "--unlimited-approval" => output.unlimited_approval = true, + other => { + return Err(Error::InvalidArgument { + reason: format!("unsupported uniswap flag {other}"), + }); + } + } + index += 1; + } + + Ok(output) + } +} + +fn parse_next(args: &[String], index: &mut usize, flag: &str) -> Result { + *index += 1; + args.get(*index) + .cloned() + .ok_or_else(|| Error::InvalidArgument { + reason: format!("{flag} requires a value"), + }) +} + +fn parse_slippage_bps(value: &str) -> Result { + let Some((whole, fractional)) = value.split_once('.') else { + return value + .parse::() + .map(|value| value * 100) + .map_err(|_| Error::InvalidArgument { + reason: "invalid slippage percent".to_string(), + }); + }; + let whole = whole.parse::().map_err(|_| Error::InvalidArgument { + reason: "invalid slippage percent".to_string(), + })?; + let mut fractional = fractional.chars().take(2).collect::(); + while fractional.len() < 2 { + fractional.push('0'); + } + let fractional = fractional + .parse::() + .map_err(|_| Error::InvalidArgument { + reason: "invalid slippage percent".to_string(), + })?; + Ok(whole * 100 + fractional) +} diff --git a/beam-apps/apps/uniswap/src/error.rs b/beam-apps/apps/uniswap/src/error.rs new file mode 100644 index 0000000..0d95f78 --- /dev/null +++ b/beam-apps/apps/uniswap/src/error.rs @@ -0,0 +1,28 @@ +pub type Result = std::result::Result; + +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum Error { + #[error("[beam-app-uniswap] unsupported command: {command}")] + UnsupportedCommand { command: String }, + + #[error("[beam-app-uniswap] invalid argument: {reason}")] + InvalidArgument { reason: String }, + + #[error("[beam-app-uniswap] invalid Uniswap response: {reason}")] + InvalidUniswapResponse { reason: String }, + + #[error("[beam-app-uniswap] unsupported Uniswap route: {route}")] + UnsupportedUniswapRoute { route: String }, + + #[error("[beam-app-uniswap] quote expired")] + QuoteExpired, + + #[error("[beam-app-uniswap] insufficient {token} balance")] + InsufficientBalance { token: String }, + + #[error("[beam-app-uniswap] integer value is invalid: {value}")] + InvalidInteger { value: String }, + + #[error("[beam-app-uniswap] address value is invalid: {value}")] + InvalidAddress { value: String }, +} diff --git a/beam-apps/apps/uniswap/src/host.rs b/beam-apps/apps/uniswap/src/host.rs new file mode 100644 index 0000000..1b9143e --- /dev/null +++ b/beam-apps/apps/uniswap/src/host.rs @@ -0,0 +1,55 @@ +use serde_json::Value; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlanContext { + pub app_id: String, + pub app_version: String, + pub chain: String, + pub manifest_sha256: String, + pub wallet: String, + pub wasm_sha256: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SwapToken { + pub address: String, + pub decimals: u8, + pub is_native: bool, + pub label: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ActionPlan { + pub app_id: String, + pub app_version: String, + pub wasm_sha256: String, + pub manifest_sha256: String, + pub command: String, + pub wallet: Option, + pub chain: String, + #[serde(default)] + pub steps: Vec, + #[serde(default)] + pub bindings: Vec, + #[serde(default)] + pub constraints: Vec, + pub expires_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ActionStep { + pub kind: String, + pub summary: String, + pub target: Option, + pub selector: Option, + pub spender: Option, + pub value: Option, + #[serde(default)] + pub metadata: Value, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct ActionBinding { + pub key: String, + pub value: String, +} diff --git a/beam-apps/apps/uniswap/src/lib.rs b/beam-apps/apps/uniswap/src/lib.rs new file mode 100644 index 0000000..a6910b2 --- /dev/null +++ b/beam-apps/apps/uniswap/src/lib.rs @@ -0,0 +1,40 @@ +mod api; +mod args; +mod error; +mod generated { + include!(concat!(env!("OUT_DIR"), "/public_api_key.rs")); +} +mod host; +mod plan; + +pub use api::{ + ApprovalResponse, QuoteRequest, QuoteResponse, SwapResponse, UniswapTransaction, + approval_spender, check_approval_payload, find_transaction, parse_quote, quote_payload, + selector, swap_payload, +}; +pub use args::SwapArgs; +pub use error::{Error, Result}; +pub use host::{ActionBinding, ActionPlan, ActionStep, PlanContext, SwapToken}; +pub use plan::{SwapPlanInput, build_swap_plan}; + +#[cfg(test)] +mod tests; + +pub fn public_api_key() -> &'static str { + generated::BEAM_UNISWAP_PUBLIC_API_KEY +} + +#[unsafe(no_mangle)] +pub extern "C" fn beam_uniswap_public_api_key_ptr() -> *const u8 { + public_api_key().as_ptr() +} + +#[unsafe(no_mangle)] +pub extern "C" fn beam_uniswap_public_api_key_len() -> usize { + public_api_key().len() +} + +#[unsafe(no_mangle)] +pub extern "C" fn beam_app_main() { + let _ = core::hint::black_box(public_api_key()); +} diff --git a/beam-apps/apps/uniswap/src/plan.rs b/beam-apps/apps/uniswap/src/plan.rs new file mode 100644 index 0000000..3a63ca3 --- /dev/null +++ b/beam-apps/apps/uniswap/src/plan.rs @@ -0,0 +1,277 @@ +use num_bigint::BigUint; +use num_traits::Num; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use crate::{ActionBinding, ActionPlan, ActionStep, PlanContext, SwapToken}; +use crate::{ + ApprovalResponse, Error, QuoteResponse, Result, SwapArgs, SwapResponse, UniswapTransaction, + approval_spender, selector, +}; + +const APPROVAL_TTL_SECONDS: u64 = 15 * 60; +const MAX_U256_HEX: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + +#[derive(Clone, Debug)] +pub struct SwapPlanInput { + pub allowance: Option, + pub amount_raw: String, + pub args: SwapArgs, + pub buy: SwapToken, + pub context: PlanContext, + pub expires_at: u64, + pub min_receive_raw: Option, + pub quote: QuoteResponse, + pub sell: SwapToken, + pub sell_balance: String, + pub approval: Option, + pub swap: SwapResponse, +} + +pub fn build_swap_plan(input: SwapPlanInput) -> Result { + ensure_balance(&input)?; + ensure_min_receive(&input.quote.amount_out, input.min_receive_raw.as_deref())?; + ensure_max_gas(&input.swap.transaction, input.args.max_gas.as_deref())?; + if input.quote.valid_for_seconds == 0 { + return Err(Error::QuoteExpired); + } + + let mut steps = Vec::new(); + let approval_spender = input + .approval + .as_ref() + .and_then(|response| response.transaction.as_ref()) + .and_then(|transaction| approval_spender(&transaction.data)); + if let Some(step) = approval_step(&input)? { + steps.push(step); + } + steps.push(swap_step(&input)); + + let expires_at = input.expires_at + input.quote.valid_for_seconds.min(APPROVAL_TTL_SECONDS); + let command = format!( + "swap {} {} {}", + input.args.sell_token, input.args.buy_token, input.args.amount + ); + let bindings = uniswap_bindings(&input, approval_spender.as_deref(), expires_at); + let constraints = plan_constraints(&input); + Ok(ActionPlan { + app_id: input.context.app_id, + app_version: input.context.app_version, + wasm_sha256: input.context.wasm_sha256, + manifest_sha256: input.context.manifest_sha256, + command, + wallet: Some(input.context.wallet), + chain: input.context.chain, + steps, + bindings, + constraints, + expires_at, + }) +} + +fn approval_step(input: &SwapPlanInput) -> Result> { + if input.sell.is_native { + return Ok(None); + } + let allowance = input.allowance.as_deref().unwrap_or("0"); + if parse_uint(allowance)? >= parse_uint(&input.amount_raw)? { + return Ok(None); + } + let Some(mut transaction) = input + .approval + .as_ref() + .and_then(|response| response.transaction.clone()) + else { + return Err(Error::InvalidUniswapResponse { + reason: "approval response missing transaction".to_string(), + }); + }; + let spender = + approval_spender(&transaction.data).ok_or_else(|| Error::InvalidUniswapResponse { + reason: "approval transaction missing spender".to_string(), + })?; + if input.args.unlimited_approval { + transaction.data = unlimited_approval_data(&spender)?; + } + let value = if input.args.unlimited_approval { + parse_uint(&format!("0x{MAX_U256_HEX}"))?.to_string() + } else { + input.amount_raw.clone() + }; + + Ok(Some(ActionStep { + kind: "erc20-approval".to_string(), + metadata: json!({ + "approval_mode": if input.args.unlimited_approval { "unlimited" } else { "exact" }, + "sell_token": input.sell.label, + "transaction": transaction_json(&transaction), + }), + selector: selector(&transaction.data), + spender: Some(spender), + summary: format!( + "Approve {} {} for Uniswap{}", + if input.args.unlimited_approval { + "unlimited".to_string() + } else { + input.args.amount.clone() + }, + input.sell.label, + if input.args.unlimited_approval { + " (higher risk)" + } else { + "" + }, + ), + target: Some(transaction.to), + value: Some(value), + })) +} + +fn swap_step(input: &SwapPlanInput) -> ActionStep { + let transaction = &input.swap.transaction; + ActionStep { + kind: "transaction".to_string(), + metadata: json!({ + "buy": input.buy.label, + "quote_id": input.quote.quote_id, + "route": input.quote.route, + "sell": input.sell.label, + "slippage_bps": input.args.slippage_bps, + "swap": input.swap.raw, + "transaction": transaction_json(transaction), + }), + selector: selector(&transaction.data), + spender: None, + summary: format!( + "Swap {} {} for {}", + input.args.amount, input.sell.label, input.buy.label + ), + target: Some(transaction.to.clone()), + value: Some(transaction.value.clone()), + } +} + +fn ensure_balance(input: &SwapPlanInput) -> Result<()> { + if parse_uint(&input.sell_balance)? < parse_uint(&input.amount_raw)? { + return Err(Error::InsufficientBalance { + token: input.sell.label.clone(), + }); + } + Ok(()) +} + +fn ensure_min_receive(amount_out: &str, min_receive: Option<&str>) -> Result<()> { + let Some(min_receive) = min_receive else { + return Ok(()); + }; + if parse_uint(amount_out)? < parse_uint(min_receive)? { + return Err(Error::InvalidArgument { + reason: "quote below minimum receive".to_string(), + }); + } + Ok(()) +} + +fn ensure_max_gas(transaction: &UniswapTransaction, max_gas: Option<&str>) -> Result<()> { + let (Some(gas_limit), Some(max_gas)) = (transaction.gas_limit.as_deref(), max_gas) else { + return Ok(()); + }; + if parse_uint(gas_limit)? > parse_uint(max_gas)? { + return Err(Error::InvalidArgument { + reason: "swap gas estimate exceeds max gas".to_string(), + }); + } + Ok(()) +} + +fn transaction_json(transaction: &UniswapTransaction) -> Value { + json!({ + "data": transaction.data, + "gas_limit": transaction.gas_limit, + "gas_price": transaction.gas_price, + "to": transaction.to, + "value": transaction.value, + }) +} + +fn plan_constraints(input: &SwapPlanInput) -> Vec { + let mut constraints = vec![ + format!("slippage_bps={}", input.args.slippage_bps), + format!("deadline_seconds={}", input.args.deadline_seconds), + format!("quoted_amount_out={}", input.quote.amount_out), + ]; + if let Some(minimum_amount_out) = &input.quote.minimum_amount_out { + constraints.push(format!("quote_minimum_amount_out={minimum_amount_out}")); + } + if let Some(min_receive) = &input.args.min_receive { + constraints.push(format!("min_receive={min_receive}")); + } + if let Some(max_gas) = &input.args.max_gas { + constraints.push(format!("max_gas={max_gas}")); + } + constraints +} + +fn uniswap_bindings( + input: &SwapPlanInput, + spender: Option<&str>, + expires_at: u64, +) -> Vec { + let mut bindings = vec![ + binding("quote_id", &input.quote.quote_id), + binding("quote_expires_at", &expires_at.to_string()), + binding("route_hash", &sha256_hex(&input.quote.route)), + binding( + "swap_calldata_hash", + &sha256_hex(&input.swap.transaction.data), + ), + binding("router", &input.swap.transaction.to), + binding("sell_token", &input.sell.address), + binding("buy_token", &input.buy.address), + binding("amount_in", &input.amount_raw), + binding("amount_out", &input.quote.amount_out), + ]; + if let Some(spender) = spender { + bindings.push(binding("spender", spender)); + } + + bindings +} + +fn binding(key: &str, value: &str) -> ActionBinding { + ActionBinding { + key: key.to_string(), + value: value.to_string(), + } +} + +fn sha256_hex(value: &str) -> String { + format!("sha256:{}", hex::encode(Sha256::digest(value.as_bytes()))) +} + +fn unlimited_approval_data(spender: &str) -> Result { + let spender = address_word(spender)?; + Ok(format!("0x095ea7b3{spender}{MAX_U256_HEX}")) +} + +fn address_word(address: &str) -> Result { + let address = address.strip_prefix("0x").unwrap_or(address); + if address.len() != 40 || !address.chars().all(|char| char.is_ascii_hexdigit()) { + return Err(Error::InvalidAddress { + value: address.to_string(), + }); + } + Ok(format!("{address:0>64}").to_ascii_lowercase()) +} + +fn parse_uint(value: &str) -> Result { + let value = value.trim(); + if let Some(hex) = value.strip_prefix("0x") { + return BigUint::from_str_radix(hex, 16).map_err(|_| Error::InvalidInteger { + value: value.to_string(), + }); + } + BigUint::from_str_radix(value, 10).map_err(|_| Error::InvalidInteger { + value: value.to_string(), + }) +} diff --git a/beam-apps/apps/uniswap/src/tests.rs b/beam-apps/apps/uniswap/src/tests.rs new file mode 100644 index 0000000..396c4df --- /dev/null +++ b/beam-apps/apps/uniswap/src/tests.rs @@ -0,0 +1,174 @@ +use serde_json::json; + +use crate::{ + ApprovalResponse, PlanContext, QuoteRequest, SwapArgs, SwapPlanInput, SwapResponse, SwapToken, + UniswapTransaction, approval_spender, build_swap_plan, parse_quote, public_api_key, selector, +}; + +#[test] +fn parses_agent_safe_swap_args() { + let args = SwapArgs::parse(&[ + "swap".to_string(), + "USDC".to_string(), + "ETH".to_string(), + "10".to_string(), + "--slippage-bps".to_string(), + "25".to_string(), + "--recipient".to_string(), + "alice".to_string(), + "--deadline-seconds".to_string(), + "300".to_string(), + "--unlimited-approval".to_string(), + ]) + .expect("parse swap args"); + + assert_eq!(args.sell_token, "USDC"); + assert_eq!(args.buy_token, "ETH"); + assert_eq!(args.slippage_bps, 25); + assert_eq!(args.recipient.as_deref(), Some("alice")); + assert_eq!(args.deadline_seconds, 300); + assert!(args.unlimited_approval); +} + +#[test] +fn parses_approval_spender_and_selector() { + let data = concat!( + "0x095ea7b3", + "000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3", + "000000000000000000000000000000000000000000000000000000000000000a", + ); + + assert_eq!(selector(data).as_deref(), Some("0x095ea7b3")); + assert_eq!( + approval_spender(data).as_deref(), + Some("0x000000000022d473030f116ddee9f6b43ac78ba3") + ); +} + +#[test] +fn quote_parser_rejects_wrong_chain_and_uniswapx() { + let request = quote_request(); + let wrong_chain = json!({ + "amountOut": "100", + "route": "classic", + "tokenInChainId": 1, + }); + parse_quote(wrong_chain, &request).expect_err("reject wrong chain"); + + let uniswapx = json!({ + "amountOut": "100", + "route": "UniswapX", + "tokenInChainId": 8453, + "tokenOutChainId": 8453, + }); + parse_quote(uniswapx, &request).expect_err("reject UniswapX route"); +} + +#[test] +fn public_api_key_is_embed_ready() { + let key = public_api_key(); + + assert_eq!(key, key.trim()); + assert!(!key.contains('\n')); + assert!(!key.contains('\r')); +} + +#[test] +fn builds_approval_and_swap_action_plan() { + let args = SwapArgs::parse(&[ + "swap".to_string(), + "USDC".to_string(), + "ETH".to_string(), + "10".to_string(), + "--min-receive".to_string(), + "90".to_string(), + ]) + .expect("parse args"); + let quote = parse_quote( + json!({ + "amountOut": "100", + "quoteId": "quote-1", + "route": "classic", + "tokenInChainId": 8453, + "tokenOutChainId": 8453, + "tokenIn": "0x1111111111111111111111111111111111111111", + "tokenOut": "0x0000000000000000000000000000000000000000", + }), + "e_request(), + ) + .expect("parse quote"); + + let plan = build_swap_plan(SwapPlanInput { + allowance: Some("0".to_string()), + amount_raw: "10000000".to_string(), + args, + buy: token("ETH", "0x0000000000000000000000000000000000000000", true), + context: PlanContext { + app_id: "uniswap".to_string(), + app_version: "1.0.0".to_string(), + chain: "base".to_string(), + manifest_sha256: "sha256:manifest".to_string(), + wallet: "0x3333333333333333333333333333333333333333".to_string(), + wasm_sha256: "sha256:wasm".to_string(), + }, + expires_at: 1_000, + min_receive_raw: Some("90".to_string()), + quote, + sell: token("USDC", "0x1111111111111111111111111111111111111111", false), + sell_balance: "10000000".to_string(), + approval: Some(ApprovalResponse { + transaction: Some(transaction("0x095ea7b3000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba30000000000000000000000000000000000000000000000000000000000989680")), + }), + swap: SwapResponse { + raw: json!({ "transaction": { "to": "0x2222222222222222222222222222222222222222" } }), + transaction: transaction("0x3593564c"), + }, + }) + .expect("build action plan"); + + assert_eq!(plan.steps.len(), 2); + assert_eq!(plan.steps[0].kind, "erc20-approval"); + assert_eq!(plan.steps[1].kind, "transaction"); + assert!( + plan.bindings + .iter() + .any(|binding| binding.key == "quote_id") + ); + assert!( + plan.bindings + .iter() + .any(|binding| binding.key == "swap_calldata_hash") + ); + assert!(plan.bindings.iter().any(|binding| binding.key == "router")); +} + +fn quote_request() -> QuoteRequest { + QuoteRequest { + amount: "10000000".to_string(), + chain_id: 8453, + recipient: "0x3333333333333333333333333333333333333333".to_string(), + slippage_bps: 50, + token_in: "0x1111111111111111111111111111111111111111".to_string(), + token_out: "0x0000000000000000000000000000000000000000".to_string(), + wallet: "0x3333333333333333333333333333333333333333".to_string(), + } +} + +fn token(label: &str, address: &str, is_native: bool) -> SwapToken { + SwapToken { + address: address.to_string(), + decimals: 18, + is_native, + label: label.to_string(), + } +} + +fn transaction(data: &str) -> UniswapTransaction { + UniswapTransaction { + data: data.to_string(), + gas_limit: Some("100000".to_string()), + gas_price: Some("1".to_string()), + to: "0x2222222222222222222222222222222222222222".to_string(), + value: "0".to_string(), + } +} diff --git a/beam-apps/fixtures/README.md b/beam-apps/fixtures/README.md new file mode 100644 index 0000000..0acd663 --- /dev/null +++ b/beam-apps/fixtures/README.md @@ -0,0 +1,12 @@ +# Beam App Registry Fixtures + +`scripts/beam-app-registry/build.py --fixtures` emits fixture bundles here: + +- `valid` - installable Uniswap registry bundle. +- `invalid-digest` - index points at a module digest that does not match the + bundled WASM artifact. +- `missing-fields` - app manifest omits a required field. +- `unsupported-beam` - app requires a future Beam version. +- `malformed-permissions` - app declares an invalid selector. +- `broad-wildcard` - app deliberately omits optional contract, selector, + and spender scopes so Beam must display broad wildcard permissions. diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/icon.svg b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/icon.svg new file mode 100644 index 0000000..bbe75a0 --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json new file mode 100644 index 0000000..80268e7 --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json @@ -0,0 +1,224 @@ +{ + "format_version": 1, + "id": "uniswap", + "display_name": "Uniswap", + "version": "1.0.0", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "min_beam_version": "0.1.2", + "wasm": { + "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "entrypoint": "beam_app_main" + }, + "catalog": { + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "sensitive_args": [], + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + } + } + ], + "permissions": { + "http": [ + { + "url": "https://trade-api.gateway.uniswap.org/v1/*" + } + ], + "chains": [ + { + "chain": "*", + "operations": [ + "read", + "simulate" + ] + } + ], + "wallet": { + "read_balances": true, + "propose_transactions": true, + "erc20_approval": true + }, + "storage": { + "app_local": true + }, + "privacy": [] + }, + "host_api": { + "privacy_reserved": true + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:4d53155fe87d58d1a3bcbedc8f312b12a377eb7a4ba3956575fdf225f764b45a" + } +} diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json.sig new file mode 100644 index 0000000..4748bf1 --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" +} diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/module.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a4fb777a5d7bf9324cf3a526f2a706535d521c84 GIT binary patch literal 593 zcmaix&u-H|5XNV0Cv{_xD=I2g2oc}_$l54LRkb~aJD0w|c5P40lJ$DEf7<$#TzG>% zP#+DqAOsxB9A-50jYhxG2g0=o0GLKs09Sz9IEeu<8KFVMQH*dfK90Az+U@Qk+rr;< zuOE+FYCGFU@Vq3^a$yW_h139V9{ZrByAg()P%2psvg8};xgrht+(kZOcZ*L(>VD*?VgYTGV(GncJ{7v|a zYIgJR{$>0R`_o_pdh|c?y%z{M=gy02!%Zi(CktQpjqU1Ck#$k3gl>Xp2$Rd;JUknu zikzdGl|7E^FsoiKW*3XueEM;HzPvc2lheg?IbWW##hfkAKfxX5Uf6~_Uz6~`l1q!M z3~IxEI#T3+G^{heII4tFOzK)%syyqi$*N8_#iR|&%WI(m6?q%HC>60rb)`_QIMki? gcCc0IhOLc%c&5lLt;v$Ei22@-D^e#L&FI$u04VgimH+?% literal 0 HcmV?d00001 diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/version.json.sig new file mode 100644 index 0000000..d6a752e --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/version.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" +} diff --git a/beam-apps/fixtures/broad-wildcard/catalog/apps.json b/beam-apps/fixtures/broad-wildcard/catalog/apps.json new file mode 100644 index 0000000..569485f --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/catalog/apps.json @@ -0,0 +1,109 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "install_command": "beam apps install uniswap", + "pinned_install_command": "beam apps install uniswap --version 1.0.0", + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "version": { + "version": "1.0.0", + "min_beam_version": "0.1.2" + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" + } +} diff --git a/beam-apps/fixtures/broad-wildcard/catalog/apps.json.sig b/beam-apps/fixtures/broad-wildcard/catalog/apps.json.sig new file mode 100644 index 0000000..e8eb833 --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/catalog/apps.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" +} diff --git a/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json b/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json new file mode 100644 index 0000000..45413a2 --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json @@ -0,0 +1,298 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "app": { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "install_commands": { + "latest": "beam apps install uniswap", + "pinned": "beam apps install uniswap --version 1.0.0", + "dry_run": "beam apps install uniswap --dry-run" + }, + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "permission_summary": { + "http": [ + "https://trade-api.gateway.uniswap.org/v1/*" + ], + "wallet": [ + "read balances", + "propose transactions", + "erc20 approvals" + ], + "approvals": "exact by default", + "selectors": [ + "0x095ea7b3", + "*" + ], + "storage": [ + "app-local" + ], + "privacy": [] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + }, + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "sensitive_args": [] + } + ], + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "manifest_summary": { + "format_version": 1, + "min_beam_version": "0.1.2", + "wasm_entrypoint": "beam_app_main" + }, + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2" + } + ], + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + } +} diff --git a/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json.sig new file mode 100644 index 0000000..486afc8 --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" +} diff --git a/beam-apps/fixtures/broad-wildcard/index.json b/beam-apps/fixtures/broad-wildcard/index.json new file mode 100644 index 0000000..4ace360 --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/index.json @@ -0,0 +1,32 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2", + "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", + "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", + "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + } + } + ] + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + } +} diff --git a/beam-apps/fixtures/broad-wildcard/index.json.sig b/beam-apps/fixtures/broad-wildcard/index.json.sig new file mode 100644 index 0000000..c10b635 --- /dev/null +++ b/beam-apps/fixtures/broad-wildcard/index.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" +} diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/icon.svg b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/icon.svg new file mode 100644 index 0000000..bbe75a0 --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json new file mode 100644 index 0000000..76c5ba2 --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json @@ -0,0 +1,295 @@ +{ + "format_version": 1, + "id": "uniswap", + "display_name": "Uniswap", + "version": "1.0.0", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "min_beam_version": "0.1.2", + "wasm": { + "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "entrypoint": "beam_app_main" + }, + "catalog": { + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "sensitive_args": [], + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + } + } + ], + "permissions": { + "http": [ + { + "url": "https://trade-api.gateway.uniswap.org/v1/*" + } + ], + "chains": [ + { + "chain": "ethereum", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "base", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "polygon", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "bnb", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "arbitrum", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "sepolia", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + } + ], + "wallet": { + "read_balances": true, + "propose_transactions": true, + "erc20_approval": true + }, + "storage": { + "app_local": true + }, + "privacy": [] + }, + "host_api": { + "privacy_reserved": true + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + } +} diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json.sig new file mode 100644 index 0000000..4748bf1 --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" +} diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/module.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a4fb777a5d7bf9324cf3a526f2a706535d521c84 GIT binary patch literal 593 zcmaix&u-H|5XNV0Cv{_xD=I2g2oc}_$l54LRkb~aJD0w|c5P40lJ$DEf7<$#TzG>% zP#+DqAOsxB9A-50jYhxG2g0=o0GLKs09Sz9IEeu<8KFVMQH*dfK90Az+U@Qk+rr;< zuOE+FYCGFU@Vq3^a$yW_h139V9{ZrByAg()P%2psvg8};xgrht+(kZOcZ*L(>VD*?VgYTGV(GncJ{7v|a zYIgJR{$>0R`_o_pdh|c?y%z{M=gy02!%Zi(CktQpjqU1Ck#$k3gl>Xp2$Rd;JUknu zikzdGl|7E^FsoiKW*3XueEM;HzPvc2lheg?IbWW##hfkAKfxX5Uf6~_Uz6~`l1q!M z3~IxEI#T3+G^{heII4tFOzK)%syyqi$*N8_#iR|&%WI(m6?q%HC>60rb)`_QIMki? gcCc0IhOLc%c&5lLt;v$Ei22@-D^e#L&FI$u04VgimH+?% literal 0 HcmV?d00001 diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/version.json.sig new file mode 100644 index 0000000..d6a752e --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/version.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" +} diff --git a/beam-apps/fixtures/invalid-digest/catalog/apps.json b/beam-apps/fixtures/invalid-digest/catalog/apps.json new file mode 100644 index 0000000..569485f --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/catalog/apps.json @@ -0,0 +1,109 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "install_command": "beam apps install uniswap", + "pinned_install_command": "beam apps install uniswap --version 1.0.0", + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "version": { + "version": "1.0.0", + "min_beam_version": "0.1.2" + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" + } +} diff --git a/beam-apps/fixtures/invalid-digest/catalog/apps.json.sig b/beam-apps/fixtures/invalid-digest/catalog/apps.json.sig new file mode 100644 index 0000000..e8eb833 --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/catalog/apps.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" +} diff --git a/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json b/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json new file mode 100644 index 0000000..45413a2 --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json @@ -0,0 +1,298 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "app": { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "install_commands": { + "latest": "beam apps install uniswap", + "pinned": "beam apps install uniswap --version 1.0.0", + "dry_run": "beam apps install uniswap --dry-run" + }, + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "permission_summary": { + "http": [ + "https://trade-api.gateway.uniswap.org/v1/*" + ], + "wallet": [ + "read balances", + "propose transactions", + "erc20 approvals" + ], + "approvals": "exact by default", + "selectors": [ + "0x095ea7b3", + "*" + ], + "storage": [ + "app-local" + ], + "privacy": [] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + }, + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "sensitive_args": [] + } + ], + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "manifest_summary": { + "format_version": 1, + "min_beam_version": "0.1.2", + "wasm_entrypoint": "beam_app_main" + }, + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2" + } + ], + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + } +} diff --git a/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json.sig new file mode 100644 index 0000000..486afc8 --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" +} diff --git a/beam-apps/fixtures/invalid-digest/index.json b/beam-apps/fixtures/invalid-digest/index.json new file mode 100644 index 0000000..9a0b6f9 --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/index.json @@ -0,0 +1,32 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2", + "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", + "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", + "module_sha256": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + } + } + ] + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:9f831d41929e401c0873a58a80aeaae76a6353ec07da62a2b1864f969536eecf" + } +} diff --git a/beam-apps/fixtures/invalid-digest/index.json.sig b/beam-apps/fixtures/invalid-digest/index.json.sig new file mode 100644 index 0000000..c10b635 --- /dev/null +++ b/beam-apps/fixtures/invalid-digest/index.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" +} diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/icon.svg b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/icon.svg new file mode 100644 index 0000000..bbe75a0 --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json new file mode 100644 index 0000000..b40b405 --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json @@ -0,0 +1,226 @@ +{ + "format_version": 1, + "id": "uniswap", + "display_name": "Uniswap", + "version": "1.0.0", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "min_beam_version": "0.1.2", + "wasm": { + "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "entrypoint": "beam_app_main" + }, + "catalog": { + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "sensitive_args": [], + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + } + } + ], + "permissions": { + "http": [ + { + "url": "https://trade-api.gateway.uniswap.org/v1/*" + } + ], + "chains": [ + { + "chain": "base", + "operations": [ + "read" + ], + "selectors": [ + "not-a-selector" + ] + } + ], + "wallet": { + "read_balances": true, + "propose_transactions": true, + "erc20_approval": true + }, + "storage": { + "app_local": true + }, + "privacy": [] + }, + "host_api": { + "privacy_reserved": true + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:a1b9a4d8928f731b0e778bdf8cb6c3075c895c9d8f1e06fff1e5cbb328a4dd51" + } +} diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json.sig new file mode 100644 index 0000000..4748bf1 --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" +} diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/module.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a4fb777a5d7bf9324cf3a526f2a706535d521c84 GIT binary patch literal 593 zcmaix&u-H|5XNV0Cv{_xD=I2g2oc}_$l54LRkb~aJD0w|c5P40lJ$DEf7<$#TzG>% zP#+DqAOsxB9A-50jYhxG2g0=o0GLKs09Sz9IEeu<8KFVMQH*dfK90Az+U@Qk+rr;< zuOE+FYCGFU@Vq3^a$yW_h139V9{ZrByAg()P%2psvg8};xgrht+(kZOcZ*L(>VD*?VgYTGV(GncJ{7v|a zYIgJR{$>0R`_o_pdh|c?y%z{M=gy02!%Zi(CktQpjqU1Ck#$k3gl>Xp2$Rd;JUknu zikzdGl|7E^FsoiKW*3XueEM;HzPvc2lheg?IbWW##hfkAKfxX5Uf6~_Uz6~`l1q!M z3~IxEI#T3+G^{heII4tFOzK)%syyqi$*N8_#iR|&%WI(m6?q%HC>60rb)`_QIMki? gcCc0IhOLc%c&5lLt;v$Ei22@-D^e#L&FI$u04VgimH+?% literal 0 HcmV?d00001 diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/version.json.sig new file mode 100644 index 0000000..d6a752e --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/version.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" +} diff --git a/beam-apps/fixtures/malformed-permissions/catalog/apps.json b/beam-apps/fixtures/malformed-permissions/catalog/apps.json new file mode 100644 index 0000000..569485f --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/catalog/apps.json @@ -0,0 +1,109 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "install_command": "beam apps install uniswap", + "pinned_install_command": "beam apps install uniswap --version 1.0.0", + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "version": { + "version": "1.0.0", + "min_beam_version": "0.1.2" + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" + } +} diff --git a/beam-apps/fixtures/malformed-permissions/catalog/apps.json.sig b/beam-apps/fixtures/malformed-permissions/catalog/apps.json.sig new file mode 100644 index 0000000..e8eb833 --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/catalog/apps.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" +} diff --git a/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json b/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json new file mode 100644 index 0000000..45413a2 --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json @@ -0,0 +1,298 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "app": { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "install_commands": { + "latest": "beam apps install uniswap", + "pinned": "beam apps install uniswap --version 1.0.0", + "dry_run": "beam apps install uniswap --dry-run" + }, + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "permission_summary": { + "http": [ + "https://trade-api.gateway.uniswap.org/v1/*" + ], + "wallet": [ + "read balances", + "propose transactions", + "erc20 approvals" + ], + "approvals": "exact by default", + "selectors": [ + "0x095ea7b3", + "*" + ], + "storage": [ + "app-local" + ], + "privacy": [] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + }, + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "sensitive_args": [] + } + ], + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "manifest_summary": { + "format_version": 1, + "min_beam_version": "0.1.2", + "wasm_entrypoint": "beam_app_main" + }, + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2" + } + ], + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + } +} diff --git a/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json.sig new file mode 100644 index 0000000..486afc8 --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" +} diff --git a/beam-apps/fixtures/malformed-permissions/index.json b/beam-apps/fixtures/malformed-permissions/index.json new file mode 100644 index 0000000..4ace360 --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/index.json @@ -0,0 +1,32 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2", + "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", + "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", + "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + } + } + ] + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + } +} diff --git a/beam-apps/fixtures/malformed-permissions/index.json.sig b/beam-apps/fixtures/malformed-permissions/index.json.sig new file mode 100644 index 0000000..c10b635 --- /dev/null +++ b/beam-apps/fixtures/malformed-permissions/index.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" +} diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/icon.svg b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/icon.svg new file mode 100644 index 0000000..bbe75a0 --- /dev/null +++ b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json new file mode 100644 index 0000000..83060f0 --- /dev/null +++ b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json @@ -0,0 +1,134 @@ +{ + "format_version": 1, + "id": "uniswap", + "display_name": "Uniswap", + "version": "1.0.0", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "min_beam_version": "0.1.2", + "wasm": { + "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "entrypoint": "beam_app_main" + }, + "catalog": { + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ] + }, + "permissions": { + "http": [ + { + "url": "https://trade-api.gateway.uniswap.org/v1/*" + } + ], + "chains": [ + { + "chain": "ethereum", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "base", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "polygon", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "bnb", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "arbitrum", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "sepolia", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + } + ], + "wallet": { + "read_balances": true, + "propose_transactions": true, + "erc20_approval": true + }, + "storage": { + "app_local": true + }, + "privacy": [] + }, + "host_api": { + "privacy_reserved": true + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:bac5e146237b53395f744e1097794b73abaa94a4ec75c6096ab3404e1e738a06" + } +} diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json.sig new file mode 100644 index 0000000..4748bf1 --- /dev/null +++ b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" +} diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/module.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a4fb777a5d7bf9324cf3a526f2a706535d521c84 GIT binary patch literal 593 zcmaix&u-H|5XNV0Cv{_xD=I2g2oc}_$l54LRkb~aJD0w|c5P40lJ$DEf7<$#TzG>% zP#+DqAOsxB9A-50jYhxG2g0=o0GLKs09Sz9IEeu<8KFVMQH*dfK90Az+U@Qk+rr;< zuOE+FYCGFU@Vq3^a$yW_h139V9{ZrByAg()P%2psvg8};xgrht+(kZOcZ*L(>VD*?VgYTGV(GncJ{7v|a zYIgJR{$>0R`_o_pdh|c?y%z{M=gy02!%Zi(CktQpjqU1Ck#$k3gl>Xp2$Rd;JUknu zikzdGl|7E^FsoiKW*3XueEM;HzPvc2lheg?IbWW##hfkAKfxX5Uf6~_Uz6~`l1q!M z3~IxEI#T3+G^{heII4tFOzK)%syyqi$*N8_#iR|&%WI(m6?q%HC>60rb)`_QIMki? gcCc0IhOLc%c&5lLt;v$Ei22@-D^e#L&FI$u04VgimH+?% literal 0 HcmV?d00001 diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/version.json.sig new file mode 100644 index 0000000..d6a752e --- /dev/null +++ b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/version.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" +} diff --git a/beam-apps/fixtures/missing-fields/catalog/apps.json b/beam-apps/fixtures/missing-fields/catalog/apps.json new file mode 100644 index 0000000..569485f --- /dev/null +++ b/beam-apps/fixtures/missing-fields/catalog/apps.json @@ -0,0 +1,109 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "install_command": "beam apps install uniswap", + "pinned_install_command": "beam apps install uniswap --version 1.0.0", + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "version": { + "version": "1.0.0", + "min_beam_version": "0.1.2" + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" + } +} diff --git a/beam-apps/fixtures/missing-fields/catalog/apps.json.sig b/beam-apps/fixtures/missing-fields/catalog/apps.json.sig new file mode 100644 index 0000000..e8eb833 --- /dev/null +++ b/beam-apps/fixtures/missing-fields/catalog/apps.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" +} diff --git a/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json b/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json new file mode 100644 index 0000000..45413a2 --- /dev/null +++ b/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json @@ -0,0 +1,298 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "app": { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "install_commands": { + "latest": "beam apps install uniswap", + "pinned": "beam apps install uniswap --version 1.0.0", + "dry_run": "beam apps install uniswap --dry-run" + }, + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "permission_summary": { + "http": [ + "https://trade-api.gateway.uniswap.org/v1/*" + ], + "wallet": [ + "read balances", + "propose transactions", + "erc20 approvals" + ], + "approvals": "exact by default", + "selectors": [ + "0x095ea7b3", + "*" + ], + "storage": [ + "app-local" + ], + "privacy": [] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + }, + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "sensitive_args": [] + } + ], + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "manifest_summary": { + "format_version": 1, + "min_beam_version": "0.1.2", + "wasm_entrypoint": "beam_app_main" + }, + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2" + } + ], + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + } +} diff --git a/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json.sig new file mode 100644 index 0000000..486afc8 --- /dev/null +++ b/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" +} diff --git a/beam-apps/fixtures/missing-fields/index.json b/beam-apps/fixtures/missing-fields/index.json new file mode 100644 index 0000000..4ace360 --- /dev/null +++ b/beam-apps/fixtures/missing-fields/index.json @@ -0,0 +1,32 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2", + "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", + "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", + "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + } + } + ] + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + } +} diff --git a/beam-apps/fixtures/missing-fields/index.json.sig b/beam-apps/fixtures/missing-fields/index.json.sig new file mode 100644 index 0000000..c10b635 --- /dev/null +++ b/beam-apps/fixtures/missing-fields/index.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" +} diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/icon.svg b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/icon.svg new file mode 100644 index 0000000..bbe75a0 --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json new file mode 100644 index 0000000..9f1acb7 --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json @@ -0,0 +1,295 @@ +{ + "format_version": 1, + "id": "uniswap", + "display_name": "Uniswap", + "version": "1.0.0", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "min_beam_version": "999.0.0", + "wasm": { + "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "entrypoint": "beam_app_main" + }, + "catalog": { + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "sensitive_args": [], + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + } + } + ], + "permissions": { + "http": [ + { + "url": "https://trade-api.gateway.uniswap.org/v1/*" + } + ], + "chains": [ + { + "chain": "ethereum", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "base", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "polygon", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "bnb", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "arbitrum", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "sepolia", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + } + ], + "wallet": { + "read_balances": true, + "propose_transactions": true, + "erc20_approval": true + }, + "storage": { + "app_local": true + }, + "privacy": [] + }, + "host_api": { + "privacy_reserved": true + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:fe22b97b09a983b2f31844a57dce22187c9fbf08ca4a175d944c10bf4e9a2beb" + } +} diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json.sig new file mode 100644 index 0000000..4748bf1 --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" +} diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/module.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a4fb777a5d7bf9324cf3a526f2a706535d521c84 GIT binary patch literal 593 zcmaix&u-H|5XNV0Cv{_xD=I2g2oc}_$l54LRkb~aJD0w|c5P40lJ$DEf7<$#TzG>% zP#+DqAOsxB9A-50jYhxG2g0=o0GLKs09Sz9IEeu<8KFVMQH*dfK90Az+U@Qk+rr;< zuOE+FYCGFU@Vq3^a$yW_h139V9{ZrByAg()P%2psvg8};xgrht+(kZOcZ*L(>VD*?VgYTGV(GncJ{7v|a zYIgJR{$>0R`_o_pdh|c?y%z{M=gy02!%Zi(CktQpjqU1Ck#$k3gl>Xp2$Rd;JUknu zikzdGl|7E^FsoiKW*3XueEM;HzPvc2lheg?IbWW##hfkAKfxX5Uf6~_Uz6~`l1q!M z3~IxEI#T3+G^{heII4tFOzK)%syyqi$*N8_#iR|&%WI(m6?q%HC>60rb)`_QIMki? gcCc0IhOLc%c&5lLt;v$Ei22@-D^e#L&FI$u04VgimH+?% literal 0 HcmV?d00001 diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/version.json.sig new file mode 100644 index 0000000..d6a752e --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/version.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" +} diff --git a/beam-apps/fixtures/unsupported-beam/catalog/apps.json b/beam-apps/fixtures/unsupported-beam/catalog/apps.json new file mode 100644 index 0000000..569485f --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/catalog/apps.json @@ -0,0 +1,109 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "install_command": "beam apps install uniswap", + "pinned_install_command": "beam apps install uniswap --version 1.0.0", + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "version": { + "version": "1.0.0", + "min_beam_version": "0.1.2" + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" + } +} diff --git a/beam-apps/fixtures/unsupported-beam/catalog/apps.json.sig b/beam-apps/fixtures/unsupported-beam/catalog/apps.json.sig new file mode 100644 index 0000000..e8eb833 --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/catalog/apps.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" +} diff --git a/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json b/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json new file mode 100644 index 0000000..45413a2 --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json @@ -0,0 +1,298 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "app": { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "install_commands": { + "latest": "beam apps install uniswap", + "pinned": "beam apps install uniswap --version 1.0.0", + "dry_run": "beam apps install uniswap --dry-run" + }, + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "permission_summary": { + "http": [ + "https://trade-api.gateway.uniswap.org/v1/*" + ], + "wallet": [ + "read balances", + "propose transactions", + "erc20 approvals" + ], + "approvals": "exact by default", + "selectors": [ + "0x095ea7b3", + "*" + ], + "storage": [ + "app-local" + ], + "privacy": [] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + }, + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "sensitive_args": [] + } + ], + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "manifest_summary": { + "format_version": 1, + "min_beam_version": "0.1.2", + "wasm_entrypoint": "beam_app_main" + }, + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2" + } + ], + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + } +} diff --git a/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json.sig new file mode 100644 index 0000000..486afc8 --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" +} diff --git a/beam-apps/fixtures/unsupported-beam/index.json b/beam-apps/fixtures/unsupported-beam/index.json new file mode 100644 index 0000000..4ace360 --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/index.json @@ -0,0 +1,32 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2", + "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", + "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", + "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + } + } + ] + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + } +} diff --git a/beam-apps/fixtures/unsupported-beam/index.json.sig b/beam-apps/fixtures/unsupported-beam/index.json.sig new file mode 100644 index 0000000..c10b635 --- /dev/null +++ b/beam-apps/fixtures/unsupported-beam/index.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" +} diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/icon.svg b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/icon.svg new file mode 100644 index 0000000..bbe75a0 --- /dev/null +++ b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json new file mode 100644 index 0000000..76c5ba2 --- /dev/null +++ b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json @@ -0,0 +1,295 @@ +{ + "format_version": 1, + "id": "uniswap", + "display_name": "Uniswap", + "version": "1.0.0", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "min_beam_version": "0.1.2", + "wasm": { + "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "entrypoint": "beam_app_main" + }, + "catalog": { + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "sensitive_args": [], + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + } + } + ], + "permissions": { + "http": [ + { + "url": "https://trade-api.gateway.uniswap.org/v1/*" + } + ], + "chains": [ + { + "chain": "ethereum", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "base", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "polygon", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "bnb", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "arbitrum", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + }, + { + "chain": "sepolia", + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ], + "selectors": [ + "0x095ea7b3", + "*" + ] + } + ], + "wallet": { + "read_balances": true, + "propose_transactions": true, + "erc20_approval": true + }, + "storage": { + "app_local": true + }, + "privacy": [] + }, + "host_api": { + "privacy_reserved": true + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + } +} diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json.sig new file mode 100644 index 0000000..4748bf1 --- /dev/null +++ b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" +} diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm new file mode 100644 index 0000000000000000000000000000000000000000..a4fb777a5d7bf9324cf3a526f2a706535d521c84 GIT binary patch literal 593 zcmaix&u-H|5XNV0Cv{_xD=I2g2oc}_$l54LRkb~aJD0w|c5P40lJ$DEf7<$#TzG>% zP#+DqAOsxB9A-50jYhxG2g0=o0GLKs09Sz9IEeu<8KFVMQH*dfK90Az+U@Qk+rr;< zuOE+FYCGFU@Vq3^a$yW_h139V9{ZrByAg()P%2psvg8};xgrht+(kZOcZ*L(>VD*?VgYTGV(GncJ{7v|a zYIgJR{$>0R`_o_pdh|c?y%z{M=gy02!%Zi(CktQpjqU1Ck#$k3gl>Xp2$Rd;JUknu zikzdGl|7E^FsoiKW*3XueEM;HzPvc2lheg?IbWW##hfkAKfxX5Uf6~_Uz6~`l1q!M z3~IxEI#T3+G^{heII4tFOzK)%syyqi$*N8_#iR|&%WI(m6?q%HC>60rb)`_QIMki? gcCc0IhOLc%c&5lLt;v$Ei22@-D^e#L&FI$u04VgimH+?% literal 0 HcmV?d00001 diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/version.json.sig new file mode 100644 index 0000000..d6a752e --- /dev/null +++ b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/version.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" +} diff --git a/beam-apps/fixtures/valid/catalog/apps.json b/beam-apps/fixtures/valid/catalog/apps.json new file mode 100644 index 0000000..569485f --- /dev/null +++ b/beam-apps/fixtures/valid/catalog/apps.json @@ -0,0 +1,109 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "install_command": "beam apps install uniswap", + "pinned_install_command": "beam apps install uniswap --version 1.0.0", + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "version": { + "version": "1.0.0", + "min_beam_version": "0.1.2" + }, + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" + } +} diff --git a/beam-apps/fixtures/valid/catalog/apps.json.sig b/beam-apps/fixtures/valid/catalog/apps.json.sig new file mode 100644 index 0000000..e8eb833 --- /dev/null +++ b/beam-apps/fixtures/valid/catalog/apps.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:83a8fa8333089b3481f93f9caacd25bae6dab5bdd4d39cdfafaec2ccbc87efab" +} diff --git a/beam-apps/fixtures/valid/catalog/apps/uniswap.json b/beam-apps/fixtures/valid/catalog/apps/uniswap.json new file mode 100644 index 0000000..45413a2 --- /dev/null +++ b/beam-apps/fixtures/valid/catalog/apps/uniswap.json @@ -0,0 +1,298 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "detail_url": "https://registry.beam.payy.network/catalog/apps/uniswap.json", + "app": { + "id": "uniswap", + "display_name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "latest_version": "1.0.0", + "min_beam_version": "0.1.2", + "install_commands": { + "latest": "beam apps install uniswap", + "pinned": "beam apps install uniswap --version 1.0.0", + "dry_run": "beam apps install uniswap --dry-run" + }, + "supported_chains": [ + { + "id": "ethereum", + "label": "Ethereum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "base", + "label": "Base", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "polygon", + "label": "Polygon", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "bnb", + "label": "BNB", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "arbitrum", + "label": "Arbitrum", + "testnet": false, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + }, + { + "id": "sepolia", + "label": "Sepolia", + "testnet": true, + "operations": [ + "read", + "simulate", + "send-transaction", + "erc20-approval" + ] + } + ], + "capability_badges": [ + "Swap", + "HTTP", + "Chain read", + "Simulate", + "Onchain TX", + "ERC-20 approval", + "App storage" + ], + "permission_summary": { + "http": [ + "https://trade-api.gateway.uniswap.org/v1/*" + ], + "wallet": [ + "read balances", + "propose transactions", + "erc20 approvals" + ], + "approvals": "exact by default", + "selectors": [ + "0x095ea7b3", + "*" + ], + "storage": [ + "app-local" + ], + "privacy": [] + }, + "commands": [ + { + "name": "swap", + "about": "Fetch a quote and prepare an exact-approval Uniswap swap", + "usage": "swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "docs": { + "summary": "Fetch a quote and prepare an exact-approval Uniswap swap", + "invocation": "beam x uniswap swap [--min-receive ] [--max-gas ] [--slippage-bps ] [--recipient ] [--deadline-seconds ] [--unlimited-approval]", + "arguments": [ + { + "name": "sell-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to spend, provided as a symbol, Beam token alias, or address." + }, + { + "name": "buy-token", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Token to receive, provided as a symbol, Beam token alias, or address." + }, + { + "name": "amount", + "kind": "string", + "required": true, + "sensitive": false, + "description": "Sell amount in human token units." + } + ], + "options": [ + { + "name": "--min-receive", + "value_name": "amount", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Minimum acceptable output amount; floors slippage." + }, + { + "name": "--max-gas", + "value_name": "wei", + "kind": "wei", + "required": false, + "sensitive": false, + "description": "Reject the plan if the estimated gas exceeds this value." + }, + { + "name": "--slippage-bps", + "value_name": "bps", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Maximum slippage in basis points." + }, + { + "name": "--recipient", + "value_name": "wallet-or-address", + "kind": "string", + "required": false, + "sensitive": false, + "description": "Send output to another Beam wallet name, ENS name, or EVM address." + }, + { + "name": "--deadline-seconds", + "value_name": "seconds", + "kind": "integer", + "required": false, + "sensitive": false, + "description": "Quote and transaction deadline window." + }, + { + "name": "--unlimited-approval", + "kind": "flag", + "required": false, + "default": "false", + "sensitive": false, + "description": "Request an unlimited ERC-20 approval instead of Beam's default exact approval." + } + ], + "examples": [ + { + "title": "Swap on Base", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice", + "description": "Prepare and approve a public swap from the alice wallet on Base." + }, + { + "title": "Prepare for an agent", + "command": "beam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json", + "description": "Create a continuation for non-interactive review and explicit approval." + } + ], + "output_notes": [ + "Returns structured output with state prepared, pending, confirmed, or dropped.", + "Includes the typed steps Beam executed and a transaction_hash once broadcast." + ] + }, + "input_schema": { + "type": "object", + "required": [ + "sell_token", + "buy_token", + "amount" + ], + "properties": { + "sell_token": { + "type": "string" + }, + "buy_token": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "min_receive": { + "type": "string" + }, + "max_gas": { + "type": "string" + }, + "slippage_bps": { + "type": "integer" + }, + "recipient": { + "type": "string" + }, + "deadline_seconds": { + "type": "integer" + }, + "unlimited_approval": { + "type": "boolean" + } + } + }, + "output_schema": { + "type": "object", + "required": [ + "state", + "steps" + ], + "properties": { + "state": { + "enum": [ + "prepared", + "pending", + "confirmed", + "dropped" + ] + }, + "steps": { + "type": "array" + }, + "transaction_hash": { + "type": "string" + } + } + }, + "sensitive_args": [] + } + ], + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "manifest_summary": { + "format_version": 1, + "min_beam_version": "0.1.2", + "wasm_entrypoint": "beam_app_main" + }, + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2" + } + ], + "icon": { + "url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/icon.svg", + "sha256": "sha256:a7588127f926d7f5aa352cdeca3ab596c4ed673df98fc964af3e9c9f27e65b0d", + "media_type": "image/svg+xml", + "alt": "Uniswap logo" + } + }, + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + } +} diff --git a/beam-apps/fixtures/valid/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/valid/catalog/apps/uniswap.json.sig new file mode 100644 index 0000000..486afc8 --- /dev/null +++ b/beam-apps/fixtures/valid/catalog/apps/uniswap.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" +} diff --git a/beam-apps/fixtures/valid/index.json b/beam-apps/fixtures/valid/index.json new file mode 100644 index 0000000..4ace360 --- /dev/null +++ b/beam-apps/fixtures/valid/index.json @@ -0,0 +1,32 @@ +{ + "format_version": 1, + "generated_at": "2026-05-26T00:00:00Z", + "apps": [ + { + "id": "uniswap", + "name": "Uniswap", + "publisher": "Payy", + "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", + "versions": [ + { + "version": "1.0.0", + "min_beam_version": "0.1.2", + "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", + "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", + "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + } + } + ] + } + ], + "signature": { + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + } +} diff --git a/beam-apps/fixtures/valid/index.json.sig b/beam-apps/fixtures/valid/index.json.sig new file mode 100644 index 0000000..c10b635 --- /dev/null +++ b/beam-apps/fixtures/valid/index.json.sig @@ -0,0 +1,5 @@ +{ + "algorithm": "sha256-dev", + "key_id": "payy-dev-2026-05", + "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" +} diff --git a/beam-apps/registry-nginx.conf b/beam-apps/registry-nginx.conf new file mode 100644 index 0000000..2e9b56d --- /dev/null +++ b/beam-apps/registry-nginx.conf @@ -0,0 +1,39 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + etag on; + + location = /health { + default_type text/plain; + add_header Cache-Control "no-store"; + return 200 "ok\n"; + } + + location ~* \.wasm$ { + types { + application/wasm wasm; + } + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location ~* \.json(\.sig)?$ { + default_type application/json; + add_header Cache-Control "public, max-age=60, must-revalidate"; + try_files $uri =404; + } + + location ~* \.svg$ { + types { + image/svg+xml svg; + } + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + location / { + add_header Cache-Control "public, max-age=300"; + try_files $uri =404; + } +} diff --git a/docker/Dockerfile.beam-app-registry b/docker/Dockerfile.beam-app-registry new file mode 100644 index 0000000..76577c7 --- /dev/null +++ b/docker/Dockerfile.beam-app-registry @@ -0,0 +1,4 @@ +FROM nginx:1.27-alpine + +COPY beam-apps/registry-nginx.conf /etc/nginx/conf.d/default.conf +COPY beam-apps/registry-bundle/ /usr/share/nginx/html/ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c21caef..fb873bf 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,7 +12,7 @@ volumes: services: postgres: - image: postgres:18 + image: postgres:17 command: [ "postgres", diff --git a/docs/public/SUMMARY.md b/docs/public/SUMMARY.md index 0076625..73f9743 100644 --- a/docs/public/SUMMARY.md +++ b/docs/public/SUMMARY.md @@ -46,6 +46,15 @@ ## Protocol * [Architecture](protocol/architecture.md) +* [Specs](protocol/specs/README.md) + * [Payy-EVM Privacy Protocol](protocol/specs/privacy-protocol/privacy-protocol.mdx) + * [Data](protocol/specs/privacy-protocol/data.mdx) + * [Circuits](protocol/specs/privacy-protocol/circuits.mdx) + * [Encryption](protocol/specs/privacy-protocol/encryption.mdx) + * [Lookup](protocol/specs/privacy-protocol/lookup.mdx) + * [Contract](protocol/specs/privacy-protocol/contract.mdx) + * [Wallet](protocol/specs/privacy-protocol/wallet.mdx) + * [Security](protocol/specs/privacy-protocol/security.mdx) * [Sequencers](protocol/sequencers.md) * [Provers](protocol/provers.md) * [Data Availability](protocol/data-availability.md) diff --git a/docs/public/protocol/specs/README.md b/docs/public/protocol/specs/README.md new file mode 100644 index 0000000..d74791c --- /dev/null +++ b/docs/public/protocol/specs/README.md @@ -0,0 +1,6 @@ +# Specs + +This section publishes the durable protocol specifications maintained in +`docs/specs/` without moving them from their canonical source location. + +* [Payy-EVM Privacy Protocol](privacy-protocol/privacy-protocol.mdx) diff --git a/docs/public/protocol/specs/privacy-protocol/circuits.mdx b/docs/public/protocol/specs/privacy-protocol/circuits.mdx new file mode 100644 index 0000000..f073b97 --- /dev/null +++ b/docs/public/protocol/specs/privacy-protocol/circuits.mdx @@ -0,0 +1 @@ +{% include "../../../../specs/privacy-protocol/circuits.mdx" %} diff --git a/docs/public/protocol/specs/privacy-protocol/contract.mdx b/docs/public/protocol/specs/privacy-protocol/contract.mdx new file mode 100644 index 0000000..3784e4c --- /dev/null +++ b/docs/public/protocol/specs/privacy-protocol/contract.mdx @@ -0,0 +1 @@ +{% include "../../../../specs/privacy-protocol/contract.mdx" %} diff --git a/docs/public/protocol/specs/privacy-protocol/data.mdx b/docs/public/protocol/specs/privacy-protocol/data.mdx new file mode 100644 index 0000000..d4f56dd --- /dev/null +++ b/docs/public/protocol/specs/privacy-protocol/data.mdx @@ -0,0 +1 @@ +{% include "../../../../specs/privacy-protocol/data.mdx" %} diff --git a/docs/public/protocol/specs/privacy-protocol/encryption.mdx b/docs/public/protocol/specs/privacy-protocol/encryption.mdx new file mode 100644 index 0000000..a72522c --- /dev/null +++ b/docs/public/protocol/specs/privacy-protocol/encryption.mdx @@ -0,0 +1 @@ +{% include "../../../../specs/privacy-protocol/encryption.mdx" %} diff --git a/docs/public/protocol/specs/privacy-protocol/lookup.mdx b/docs/public/protocol/specs/privacy-protocol/lookup.mdx new file mode 100644 index 0000000..ff9906d --- /dev/null +++ b/docs/public/protocol/specs/privacy-protocol/lookup.mdx @@ -0,0 +1 @@ +{% include "../../../../specs/privacy-protocol/lookup.mdx" %} diff --git a/docs/public/protocol/specs/privacy-protocol/privacy-protocol.mdx b/docs/public/protocol/specs/privacy-protocol/privacy-protocol.mdx new file mode 100644 index 0000000..668b3e8 --- /dev/null +++ b/docs/public/protocol/specs/privacy-protocol/privacy-protocol.mdx @@ -0,0 +1 @@ +{% include "../../../../specs/privacy-protocol/privacy-protocol.mdx" %} diff --git a/docs/public/protocol/specs/privacy-protocol/security.mdx b/docs/public/protocol/specs/privacy-protocol/security.mdx new file mode 100644 index 0000000..118cbae --- /dev/null +++ b/docs/public/protocol/specs/privacy-protocol/security.mdx @@ -0,0 +1 @@ +{% include "../../../../specs/privacy-protocol/security.mdx" %} diff --git a/docs/public/protocol/specs/privacy-protocol/wallet.mdx b/docs/public/protocol/specs/privacy-protocol/wallet.mdx new file mode 100644 index 0000000..9f59df5 --- /dev/null +++ b/docs/public/protocol/specs/privacy-protocol/wallet.mdx @@ -0,0 +1 @@ +{% include "../../../../specs/privacy-protocol/wallet.mdx" %} diff --git a/docs/specs/README.md b/docs/specs/README.md new file mode 100644 index 0000000..d6cfe1f --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,11 @@ +# Specs + +- `docs/specs/` contains the complete durable specification for shipped features. +- Should remain consistent with implementation at all times +- Keep `docs/specs/` complete, even when some material is also presented elsewhere - linking to impl README.md is acceptable. Linking outside of docs/ is also acceptable. You can move material to respective crates/packages when it makes sense. +- Split large specs across multiple files so each spec file stays under 250 lines. +- Keep public-facing documentation in `docs/public/`, even if it is narrower or more selective than the full specification. +- Duplication between `docs/specs/` and `docs/public/` is acceptable when needed, because `docs/specs/` must remain the complete spec. +- Specs are authored as `.mdx` files. +- After making changes under `docs/specs/`, run `node docs/tools/spec-lint docs/specs/* docs/specs-wip/*`. +- See `docs/dev/tools/spec-lint.md` for frontmatter, MDX tags, and code-anchor conventions. diff --git a/docs/specs/privacy-protocol/circuits.mdx b/docs/specs/privacy-protocol/circuits.mdx new file mode 100644 index 0000000..3498764 --- /dev/null +++ b/docs/specs/privacy-protocol/circuits.mdx @@ -0,0 +1,452 @@ +--- +depends_on: + - contract.mdx +impl: + - noir/evm/mint + - noir/evm/burn + - noir/evm/transfer_send + - noir/evm/transfer_claim + - noir/evm/common/src + - noir/Nargo.toml + - pkg/payy-evm/contracts + - pkg/payy-evm/src/evm/precompiles + - pkg/payy-evm/tests/common +--- + +## Authentication Model + +### Overview + + +All note ownership and spend authorization use the same primitive: Schnorr verification on Grumpkin against a public key whose owner hash becomes the note owner. Wallet-facing interfaces use the compact 32-byte public-key form only for off-chain transmission; circuits consume the decoded affine `x/y` coordinates and derive `owner = Poseidon(x, y)`. Circuits verify one signature for each distinct non-padding owner participating in the transaction. In practice: + +- `mint`, `burn`, and `transfer_send` verify one owner signature +- `transfer_claim` verifies two owner signatures: the recipient's wallet-chain owner key and the incoming-note owner key + + +### Owner Auth (Schnorr) + + +For each required owner authorization, the circuit: + +1. Takes the signature and decoded public key coordinates as private input +2. Verifies the Schnorr signature over the 32-byte big-endian encoding of `tx_commitment` using Noir stdlib `std::schnorr::verify_signature` +3. Derives `signer = Poseidon(public_key_x, public_key_y)` +4. Asserts `signer` matches the owner field of the note or output chain being authorized + + + +Output notes are not authenticated by a separate primitive. Instead, the relevant owner signature binds the signer to the transaction and the circuit asserts that the signed owner matches the output note owner where required. + + +### Auth Rules Per Circuit Category + + +| Circuit | Required owner signatures | Notes | +|---------|---------------------------|-------| +| **Mint** | Output owner | Required even when the input is padding, to prevent nonce-hash griefing. When the input is real, `output.owner == input.owner` so the same signature authorizes both. | +| **Burn** | Input/change owner | Authorizes the spend and binds the change note owner. | +| **Transfer** (send) | Sender owner | Authorizes the sender input and its continuing change note. The recipient output is already recipient-owned and is later merged via `transfer_claim`. | +| **Transfer** (claim) | Recipient chain owner + incoming-note owner | The recipient owner binds the merged output / nonce chain. The second check authorizes spending the incoming note and may reuse the same recipient key when the incoming note is already recipient-owned. | + + + +`mint` always requires an output-owner signature, even when the input is padding — otherwise a malicious user could mint a note for another owner's address and occupy their `nonce_hash = 0` slot. + + + +`transfer_claim` always requires a recipient-owner signature, even when the user's own input is padding — otherwise someone who learns an incoming note and its Merkle data could claim it into another owner's nonce chain. + + +--- + +## Circuit Categories & Variants + +### Design Principle + + +ZK circuits have fixed cost — unused branches still consume constraints. The protocol therefore keeps a small fixed set of operation-shape variants rather than multiplying circuits by auth scheme or minor note-shape differences. The wallet client selects the matching circuit variant for the transaction shape. The `payy-evm` sequencer accepts any valid variant for its category. + + + +There are four circuit variants in scope: `mint`, `burn`, `transfer_send`, and `transfer_claim`. `mint` and `transfer_claim` each absorb their first-note vs existing-note cases internally via padding branches; `transfer` is the only category that still splits into two distinct variants. Auth no longer creates additional circuit forks. Each variant has a distinct verification key hash. The [`privacy_proof_verify`](contract.mdx) precompile maps each key hash to a distinct variant id, but all four variants still share the exact same public-input vector shape. + + +### Public Input Ordering Convention + + +All variants expose the same 33 public inputs in the same order, so the bridge and any other consumer can decode `publicInputs` directly without branching on proof-specific layouts. + + + +``` + 1. chain_id + 2. bridge_address + 3. recent_root + 4. input_nullifier_0 + 5. input_nullifier_1 + 6. output_commitment_0 + 7. output_commitment_1 + 8. nonce_hash + 9. user_encrypted_key_hash +10. recipient_encrypted_key_hash +11. sender_encrypted_note_0 +12. sender_encrypted_note_1 +13. sender_encrypted_note_2 +14. sender_encrypted_note_3 +15. sender_encrypted_note_4 +16. recipient_encrypted_note_0 +17. recipient_encrypted_note_1 +18. recipient_encrypted_note_2 +19. recipient_encrypted_note_3 +20. recipient_encrypted_note_4 +21. sender_chain_encrypted_key_0 +22. sender_chain_encrypted_key_1 +23. sender_chain_encrypted_key_2 +24. recipient_chain_encrypted_key_0 +25. recipient_chain_encrypted_key_1 +26. recipient_chain_encrypted_key_2 +27. chain_public_key_x +28. chain_public_key_y +29. token +30. burn_recipient +31. value +32. mint_from +33. receive_prefix +``` + + + +`kind` remains implicit from `verification_key_hash` and is not included in the public inputs. + + + +Every variant publishes all 33 entries. Any field that is not semantically used by a variant must be set to `0` and circuit-constrained to `0`. This applies to: + +- missing nullifier / commitment slots +- the entire recipient note / key / prefix surface outside `transfer_send` +- `token`, `burn_recipient`, `value`, and `mint_from` on transfer circuits +- `burn_recipient` on mint +- `mint_from` on burn + + +### Mint Category + + +Deposits funds from the EVM layer into the privacy pool. A single `mint` circuit handles both first-time deposits (padding input) and deposits to an existing note. + + + +`output_note.value == input_note.value + mint_value` + + +#### Variant: `mint` + + +``` +Private Inputs: + input_note: Note // Existing note, or padding (kind=0) for first deposit + input_merkle_path: [Field; 160] + output_note: Note + owner_signature: OwnerSignature // Schnorr owner signature (see [data.mdx](data.mdx)) + sender_symmetric_key: Field + +Public Inputs: + canonical_public_inputs: [Field; 33] // Shared canonical vector + non_zero_slots: + recent_root + input_nullifier_0 // Zero for padding input + output_commitment_0 + nonce_hash + user_encrypted_key_hash + sender_encrypted_note[0..4] + sender_chain_encrypted_key[0..2] + chain_public_key_x + chain_public_key_y + token // ERC-20 contract address + value // mint_value + mint_from // Public EVM account funding the deposit + zero_constrained_slots: + input_nullifier_1 + output_commitment_1 + recipient_encrypted_key_hash + receive_prefix + recipient_encrypted_note[0..4] + recipient_chain_encrypted_key[0..2] + burn_recipient + +Constraints: + 1. output_note.kind == 1 + 2. output_note.value == input_note.value + mint_value + 3. Verify Schnorr owner_signature over tx_commitment + 4. Poseidon(owner_signature.public_key_x, owner_signature.public_key_y) == output_note.owner + 5. output_note.token == token + 6. input_note.value fits in 240 bits + 7. output_note.value fits in 240 bits + 8. mint_value fits in 240 bits + 9. mint_value > 0 + 10. mint_from != 0 + 11. user_encrypted_key_hash != 0 + 12. recipient_encrypted_key_hash == 0 + 13. receive_prefix == 0 + 14. If input is padding (kind == 0): + a. input_note fields are all 0 // Enforced by note_commitment — prevents value inflation and other padding-slot tampering + b. output_note.nonce == 0 + c. input_nullifier == 0 // Prevents publishing arbitrary tree elements via padding + d. nonce_hash == Poseidon(kind, token, owner, 0, 0) (see [lookup.mdx](lookup.mdx)) + 15. If input is real (kind == 1): + a. input_note.kind == 1 + b. output_note.nonce == input_note.nonce + 1 + c. output_note.owner == input_note.owner + d. output_note.token == input_note.token + e. input_note.token == token + f. commitment(input_note) verified against recent_root via input_merkle_path + g. nullifier(input_note, commitment(input_note)) == input_nullifier + h. nonce_hash == Poseidon(kind, token, owner, output_note.nonce, input_note.psi) (see [lookup.mdx](lookup.mdx)) + 16. commitment(output_note) == output_commitment + 17. Encrypt output_note → sender_encrypted_note + 18. Encrypt sender_symmetric_key with chain_public_key → sender_chain_encrypted_key +``` + + +### Burn Category + + +Withdraws funds from the privacy pool to an EVM address. + + + +`input_value == output_value + burn_value` + + +#### Variant: `burn` + + +``` +Private Inputs: + input_note: Note + input_merkle_path: [Field; 160] + output_note: Note // Change note (may be zero-value) + owner_signature: OwnerSignature // Schnorr owner signature (see [data.mdx](data.mdx)) + burn_recipient_private: Field // Prevents front-running + sender_symmetric_key: Field + +Public Inputs: + canonical_public_inputs: [Field; 33] // Shared canonical vector + non_zero_slots: + recent_root + input_nullifier_0 + output_commitment_0 + nonce_hash + user_encrypted_key_hash + sender_encrypted_note[0..4] + sender_chain_encrypted_key[0..2] + chain_public_key_x + chain_public_key_y + token + burn_recipient // EVM address receiving the withdrawal + value // burn_value + zero_constrained_slots: + input_nullifier_1 + output_commitment_1 + recipient_encrypted_key_hash + receive_prefix + recipient_encrypted_note[0..4] + recipient_chain_encrypted_key[0..2] + mint_from + +Constraints: + 1. input_note.kind == 1 + 2. output_note.kind == 1 + 3. input_note.value == output_note.value + burn_value + 4. output_note.nonce == input_note.nonce + 1 + 5. output_note.owner == input_note.owner + 6. output_note.token == input_note.token + 7. input_note.token == token + 8. input_note.value fits in 240 bits + 9. output_note.value fits in 240 bits + 10. burn_value fits in 240 bits + 11. burn_value > 0 + 12. user_encrypted_key_hash != 0 + 13. recipient_encrypted_key_hash == 0 + 14. receive_prefix == 0 + 15. burn_recipient_private == burn_recipient + 16. Verify Schnorr owner_signature over tx_commitment + 17. Poseidon(owner_signature.public_key_x, owner_signature.public_key_y) == input_note.owner + 18. commitment(input_note) verified against recent_root via input_merkle_path + 19. nullifier(input_note, commitment(input_note)) == input_nullifier + 20. commitment(output_note) == output_commitment + 21. nonce_hash == Poseidon(kind, token, owner, output_note.nonce, input_note.psi) (see [lookup.mdx](lookup.mdx)) + 22. Encrypt output_note → sender_encrypted_note + 23. Encrypt sender_symmetric_key with chain_public_key → sender_chain_encrypted_key +``` + + +### Transfer Category + + +Moves value within the privacy pool. Covers: sending to another user (recipient-owned incoming note), claiming a received note into the wallet chain, and self-transfers (re-randomize psi). + + + +`sum(input_values) == sum(output_values)` + + +#### Variant: `transfer_send` + + +Spend own note, create a recipient-owned incoming note, and publish an on-chain discovery log. The sender receives an encrypted continuation note for their own wallet chain. The recipient receives a separately encrypted incoming note whose `owner` is already the owner hash derived from the recipient's shared private address. The bridge emits `{ prefix6, txHash }`, where `prefix6` is derived from that owner hash and is circuit-bound. + + + +``` +Private Inputs: + input_note: Note // Sender's current note + input_merkle_path: [Field; 160] + output_note_self: Note // Sender's updated note (change) + output_note_recv: Note // Recipient-owned incoming note + owner_signature: OwnerSignature // Schnorr sender signature (see [data.mdx](data.mdx)) + sender_symmetric_key: Field + recipient_symmetric_key: Field + +Public Inputs: + canonical_public_inputs: [Field; 33] // Shared canonical vector + non_zero_slots: + recent_root + input_nullifier_0 + output_commitment_0 // Sender's new commitment + output_commitment_1 // Recipient-owned incoming commitment + nonce_hash + user_encrypted_key_hash + recipient_encrypted_key_hash + receive_prefix + sender_encrypted_note[0..4] + recipient_encrypted_note[0..4] + sender_chain_encrypted_key[0..2] + recipient_chain_encrypted_key[0..2] + chain_public_key_x + chain_public_key_y + zero_constrained_slots: + input_nullifier_1 + token + burn_recipient + value + mint_from + +Constraints: + 1. input_note.kind == 1 + 2. output_note_self.kind == 1 + 3. output_note_recv.kind == 1 + 4. input_note.value == output_note_self.value + output_note_recv.value + 5. output_note_recv.value > 0 + 6. output_note_self.nonce == input_note.nonce + 1 + 7. output_note_self.owner == input_note.owner + 8. output_note_self.token == input_note.token + 9. output_note_recv.token == input_note.token + 10. output_note_recv.nonce == 0 + 11. input_note.value, output_note_self.value, output_note_recv.value all fit in 240 bits + 12. user_encrypted_key_hash != 0 + 13. recipient_encrypted_key_hash != 0 + 14. Verify Schnorr owner_signature over tx_commitment + 15. Poseidon(owner_signature.public_key_x, owner_signature.public_key_y) == input_note.owner + 16. commitment(input_note) verified against recent_root via input_merkle_path + 17. nullifier(input_note, commitment(input_note)) == input_nullifier + 18. commitment(output_note_self) == output_commitment_0 + 19. commitment(output_note_recv) == output_commitment_1 + 20. nonce_hash == Poseidon(kind, token, owner, output_note_self.nonce, input_note.psi) (see [lookup.mdx](lookup.mdx)) + 21. receive_prefix == first6bytes(owner_be_bytes(output_note_recv.owner)) + 22. Encrypt output_note_self → sender_encrypted_note + 23. Encrypt output_note_recv → recipient_encrypted_note + 24. Encrypt sender_symmetric_key with chain_public_key → sender_chain_encrypted_key + 25. Encrypt recipient_symmetric_key with chain_public_key → recipient_chain_encrypted_key +``` + + + +`transfer_send` fixes `output_note_recv.nonce = 0`. Recipient-owned incoming notes are discovered through the prefix log and later merged into the recipient's standard wallet chain with `transfer_claim`. + + + +`transfer_send` requires `output_note_recv.value > 0`. Zero-value direct sends are rejected so funded senders cannot roll their own wallet note forward while publishing recipient-discovery logs and recipient-side ciphertext for arbitrary prefixes without actually transferring value. Zero-value continuation notes remain allowed in other flows, such as a full-balance burn that leaves zero change. + + + +`transfer_send` creates a recipient-owned incoming note. The proof binds the recipient owner through `output_note_recv.owner`, binds its discovery bucket through `receive_prefix`, and binds the submitted recipient key bundle by hash through `recipient_encrypted_key_hash`. It does not prove that this bundle was constructed from the same public key whose Poseidon hash became the note owner. + + +#### Variant: `transfer_claim` + + +Claim an incoming note. A single circuit handles both claiming into an existing note (merge) and claiming as a first note for this token (padding input). The circuit still verifies two Schnorr signatures, but in the direct-send case both checks may use the same recipient key and identical signature bytes. + + + +``` +Private Inputs: + input_note_own: Note // User's current note, or padding (kind=0) for first claim + input_note_incoming: Note // Incoming note being claimed + input_merkle_path_own:[Field; 160] + input_merkle_path_incoming:[Field; 160] + output_note: Note // User's updated note (merged value) + recipient_signature: OwnerSignature // Recipient owner signature (see [data.mdx](data.mdx)) + incoming_note_signature: OwnerSignature // Incoming note owner signature (see [data.mdx](data.mdx)) + sender_symmetric_key: Field + +Public Inputs: + canonical_public_inputs: [Field; 33] // Shared canonical vector + non_zero_slots: + recent_root + input_nullifier_0 // Nullifier for own note (zero for padding) + input_nullifier_1 // Nullifier for incoming note + output_commitment_0 + nonce_hash + user_encrypted_key_hash + sender_encrypted_note[0..4] + sender_chain_encrypted_key[0..2] + chain_public_key_x + chain_public_key_y + zero_constrained_slots: + output_commitment_1 + recipient_encrypted_key_hash + receive_prefix + recipient_encrypted_note[0..4] + recipient_chain_encrypted_key[0..2] + token + burn_recipient + value + mint_from + +Constraints: + 1. output_note.kind == 1 + 2. input_note_incoming.kind == 1 + 3. input_note_own.value + input_note_incoming.value == output_note.value + 4. Verify recipient_signature over tx_commitment + 5. Poseidon(recipient_signature.public_key_x, recipient_signature.public_key_y) == output_note.owner + 6. Verify incoming_note_signature over tx_commitment + 7. Poseidon(incoming_note_signature.public_key_x, incoming_note_signature.public_key_y) == input_note_incoming.owner + 8. commitment(input_note_incoming) verified against recent_root via input_merkle_path_incoming + 9. nullifier(input_note_incoming, commitment) == input_nullifier_1 + 10. input_note_own.value, input_note_incoming.value, output_note.value all fit in 240 bits + 11. output_note.token == input_note_incoming.token + 12. user_encrypted_key_hash != 0 + 13. recipient_encrypted_key_hash == 0 + 14. receive_prefix == 0 + 15. If input_own is padding (kind == 0): + a. input_note_own fields are all 0 // Enforced by note_commitment — prevents value inflation and other padding-slot tampering + b. output_note.nonce == 0 + c. input_nullifier_0 == 0 // Prevents publishing arbitrary tree elements via padding + d. nonce_hash == Poseidon(kind, token, owner, 0, 0) (see [lookup.mdx](lookup.mdx)) + 16. If input_own is real (kind == 1): + a. input_note_own.kind == 1 + b. output_note.nonce == input_note_own.nonce + 1 + c. output_note.owner == input_note_own.owner + d. output_note.token == input_note_own.token + e. input_note_incoming.token == input_note_own.token + f. commitment(input_note_own) verified against recent_root via input_merkle_path_own + g. nullifier(input_note_own, commitment) == input_nullifier_0 + h. nonce_hash == Poseidon(kind, token, owner, output_note.nonce, input_note_own.psi) (see [lookup.mdx](lookup.mdx)) + 17. commitment(output_note) == output_commitment + 18. Encrypt output_note → sender_encrypted_note + 19. Encrypt sender_symmetric_key with chain_public_key → sender_chain_encrypted_key +``` + diff --git a/docs/specs/privacy-protocol/contract.mdx b/docs/specs/privacy-protocol/contract.mdx new file mode 100644 index 0000000..587a428 --- /dev/null +++ b/docs/specs/privacy-protocol/contract.mdx @@ -0,0 +1,363 @@ +--- +depends_on: + - circuits.mdx +impl: + - pkg/payy-evm/contracts + - pkg/payy-evm/src/evm/precompiles + - noir/evm/mint + - noir/evm/burn + - noir/evm/transfer_send + - noir/evm/transfer_claim + - noir/evm/common/src + - noir/common/src + - noir/Nargo.toml + - noir/generate_fixtures.sh +--- + +## Merkle Tree + +### Specification + + +The privacy pool uses the existing SMIRK (Sparse Merkle Tree) implementation: + +- **Depth**: 160 levels (capacity: 2^160) +- **Hash function**: Poseidon (2-element) +- **Leaf positioning**: Based on bit decomposition of the commitment (bit i: 0 = left, 1 = right) +- **Empty leaf**: 0 +- **Path validation**: `root_from_leaf(commitment, path) == recent_root` + + +### On-Chain State + + +The Merkle tree stores **both commitments and nullifiers** as leaves: + +- The `Rollup` contract manages the tree via SMIRK precompiles +- A ring buffer of the last 1024 roots is maintained for proof validation windows +- `isRecentRoot(root)` verifies a root is within the valid window +- Commitments are inserted via `Rollup.add(commitment)` — creates spendable notes +- Nullifiers are inserted via `Rollup.add(nullifier)` — marks notes as spent + + + +`!ROLLUP.exists(element)` is checked before inserting, preventing duplicates — this is the double-spend check for nullifiers and the duplicate-commitment check for outputs. + + +### Nullifier Tracking + + +Nullifiers are stored in the same Merkle tree as commitments. Before processing a proof: + +1. Verify each non-zero nullifier does not already exist in the tree: `!ROLLUP.exists(nullifier)` +2. Insert each non-zero nullifier into the tree: `ROLLUP.add(nullifier)` + +This prevents double-spending: once a nullifier is in the tree, any proof reusing it will fail the existence check. + + +--- + +## PrivacyBridge Contract Interface + +### Updated Interface + + +Key externally referenced APIs: + +- updateChainPublicKey - governance-only chain public key rotation. Reverts when called by any account other than `GOVERNANCE`. +- getRoot - read the current Merkle root tracked by the bridge +- getMerklePath - fetch the Merkle witness for a commitment against a recent root +- elementExists - convenience membership check against the bridge Merkle tree +- computeTxHash - pure helper for the canonical bridge `txnHash` +- getTxnHashByNonceHash - lookup from `nonce_hash` to `txn_hash` +- getTxnHashByCommitment - lookup from `commitment` to `txn_hash` +- getTxnData - fetch stored `TxnData` by `txn_hash` +- ExternalTransfer - event emitted by `transfer_send` carrying `{ prefix6, txHash }` + + + +```solidity +contract PrivacyBridge { + // Circuit variant ids returned by privacy_proof_verify + uint8 constant KIND_TRANSFER_SEND = 1; + uint8 constant KIND_BURN = 2; + uint8 constant KIND_MINT = 3; + uint8 constant KIND_TRANSFER_CLAIM = 4; + + // Chain encryption key (embedded-curve point) + uint256 public chainPublicKeyX; + uint256 public chainPublicKeyY; + + // Transaction data lookups + mapping(bytes32 => bytes32) private _nonceHashToTxnHash; // nonce_hash → txn_hash + mapping(bytes32 => bytes32) private _commitmentToTxnHash; // commitment → txn_hash + mapping(bytes32 => TxnData) private _txnHashToData; // txn_hash → transaction data + + event ExternalTransfer(bytes6 indexed prefix6, bytes32 indexed txHash); + + struct TxnData { + bytes32 verificationKeyHash; // Identifies the circuit variant (unencrypted) + bytes32[5] senderEncryptedNote; // Sender-visible output-note ciphertext + bytes32[5] recipientEncryptedNote; // Recipient-visible output-note ciphertext; left unset outside transfer_send, reads as zero + bytes32[3] senderChainEncryptedKey; // Chain-encrypted key bundle for senderEncryptedNote + bytes32[3] recipientChainEncryptedKey; // Chain-encrypted key bundle for recipientEncryptedNote; left unset outside transfer_send, reads as zero + bytes32[4] userEncryptedKey; // Sender self-decryption key bundle (128 bytes fixed) + bytes32[4] recipientEncryptedKey; // Recipient key bundle (128 bytes fixed); left unset outside transfer_send, reads as zero + bytes32 memo; // Opaque memo word; only written when non-zero; absent values read as zero; not zk-bound + } + + function transfer( + bytes32 verificationKeyHash, + bytes calldata proof, + bytes32[] calldata publicInputs, + bytes32[4] userEncryptedKey, // Verified against user_encrypted_key_hash in public inputs + bytes32[4] recipientEncryptedKey, // Verified against recipient_encrypted_key_hash on transfer_send + bytes32 memo + ) external; + + function burn( + bytes32 verificationKeyHash, + bytes calldata proof, + bytes32[] calldata publicInputs, + bytes32[4] userEncryptedKey + ) external; + + function mint( + bytes32 verificationKeyHash, + bytes calldata proof, + bytes32[] calldata publicInputs, + bytes32[4] userEncryptedKey + ) external; + + // Chain key management + function updateChainPublicKey( + uint256 newX, + uint256 newY + ) external; // restricted to GOVERNANCE (see deployment notes below) + + // Merkle helpers + function elementExists(bytes32 element) external view returns (bool); + + function computeTxHash( + bytes32 verificationKeyHash, + bytes calldata proof, + bytes32[] calldata publicInputs + ) external pure returns (bytes32); + + function getMerklePath( + bytes32 commitment + ) external view returns (bytes32 root, bytes32[] memory siblings); + + function getRoot() external view returns (bytes32 root); + + // Lookups + function getTxnHashByNonceHash( + bytes32 nonceHash + ) external view returns (bytes32); + + function getTxnHashByCommitment( + bytes32 commitment + ) external view returns (bytes32); + + function getTxnData( + bytes32 txnHash + ) external view returns (TxnData memory); + + function getChainPublicKey() + external view returns (uint256 x, uint256 y); +} +``` + + + +Beyond the transaction-data lookups, the bridge exposes a small Merkle-helper read +surface used by SDKs and wallets: + +- `getRoot()` returns the current bridge Merkle root +- `getMerklePath(commitment)` returns a recent root plus the sibling vector needed to + witness that commitment against that root +- `elementExists(element)` returns whether the element is already present in the + bridge Merkle tree +- `computeTxHash(verificationKeyHash, proof, publicInputs)` returns the canonical + `txnHash` identity the bridge would use for that proof payload + +These helpers are read-only conveniences over the bridge's current Merkle state and +transaction-hash rules. They do not mutate bridge storage. + + + +`TxnData` is a fixed-width retrieval shape, not a zero-filled write contract. The bridge writes directly into the fresh `_txnHashToData[txnHash]` entry and omits semantically absent fields instead of explicitly storing zeroes for them: + +- all variants write `verificationKeyHash`, `senderEncryptedNote`, `senderChainEncryptedKey`, and `userEncryptedKey` +- only `transfer_send` writes `recipientEncryptedNote`, `recipientChainEncryptedKey`, and `recipientEncryptedKey` +- `memo` is written only when it is non-zero + +Unread storage defaults to zero, so `getTxnData(txnHash)` still returns the canonical zero value for omitted fields without paying redundant zero-value `SSTORE`s. + + +### Processing Flow + + +For each transaction: + +1. Verify `publicInputs.length == CANONICAL_PUBLIC_INPUTS_LEN` (`33`). Reverts when the decoded public-inputs vector is not exactly 33 elements. +2. Verify proof via privacy_proof_verify precompile. Reverts when the barretenberg verifier rejects the proof for the registered verification key. +3. Decode `nonce_hash`, `chain_id`, `bridge_address`, `user_encrypted_key_hash`, `recipient_encrypted_key_hash`, `senderEncryptedNote`, `recipientEncryptedNote`, `senderChainEncryptedKey`, `recipientChainEncryptedKey`, `mint_from`, `token`, `burn_recipient`, `receive_prefix`, and other fields directly from the canonical `publicInputs` layout in [circuits.mdx](circuits.mdx). Any field word interpreted as an EVM address (`token`, `mint_from`, `burn_recipient`) whose raw value exceeds `type(uint160).max` is rejected by _fieldToAddress with the custom error AddressFieldOverflow(fieldWord). Without this, a prover could set high bits on these fields that silently truncate to a different address. +4. Verify `recent_root != 0`. Reverts when the decoded `recent_root` is zero. +5. Verify `nonce_hash != 0`. Reverts when the decoded `nonce_hash` is zero. +6. Verify `ROLLUP.isRecentRoot(recent_root)` is true. Reverts when `recent_root` is not within the last 1024 roots — the proof was built against a state the contract no longer considers recent. +7. Verify `chain_id == block.chainid` (prevents cross-chain replay). Reverts when the proof's `chain_id` does not match `block.chainid`. +8. Verify `bridge_address == address(this)` (prevents cross-contract replay). Reverts when the proof's `bridge_address` is not this contract. +9. Verify `chain_public_key == (chainPublicKeyX, chainPublicKeyY)` (ensures encryption targets the real chain key). Reverts when the proof's `chain_public_key` differs from the bridge's current storage — typically a proof built against an older key after rotation. +10. Verify `field_safe_hash(userEncryptedKey) == user_encrypted_key_hash` (prevents front-running substitution). Reverts when the calldata `userEncryptedKey` does not hash to the committed `user_encrypted_key_hash`. +11. Verify the decoded proof variant matches the called entrypoint. `burn` accepts only `KIND_BURN`, `mint` accepts only `KIND_MINT`, and `transfer` accepts only `KIND_TRANSFER_SEND` or `KIND_TRANSFER_CLAIM`. Reverts when a proof variant is submitted to the wrong entrypoint. +12. For `mint` and `burn`, the bridge does not perform additional recipient-side `require(...)` checks beyond proof verification and entrypoint matching. Instead, the respective circuits constrain `recipient_encrypted_key_hash == 0`, `receive_prefix == 0`, and the unused recipient-side public-input slots to zero. +13. For `transfer(...)`, branch on the decoded proof variant. When `kind == KIND_TRANSFER_CLAIM`, verify `recipient_encrypted_key_hash == 0`, `recipientEncryptedKey` is all zeroes, `receive_prefix == 0`, and `memo == bytes32(0)`. +14. When `kind == KIND_TRANSFER_SEND`, verify `recipient_encrypted_key_hash != 0` and `field_safe_hash(recipientEncryptedKey) == recipient_encrypted_key_hash`. `receive_prefix` is not checked for non-zero at the bridge layer because the circuit already constrains it to `first6bytes(owner_be_bytes(output_note_recv.owner))`, and `0x000000000000` is a valid derived prefix for the rare recipient owner hash that begins with six zero bytes. Revert with `recipient-encrypted-key-hash-mismatch` when the recipient key bundle does not match the committed `recipient_encrypted_key_hash`. +15. For burns: verify `burn_recipient != 0`. Reverts when a burn proof has `burn_recipient == 0`. +16. For mints: verify `mint_value > 0` (reverts when a mint proof has `mint_value == 0`) and `mint_from == msg.sender` (reverts when the proof's `mint_from` is not the transaction's `msg.sender`, preventing a third party from consuming another account's ERC-20 approval). +17. For each non-zero nullifier: verify `!ROLLUP.exists(nullifier)` (reverts on double-spend attempts where the nullifier is already in the tree), then insert it via `ROLLUP.add(nullifier)`. +18. For each non-zero output commitment: verify `!ROLLUP.exists(commitment)` (reverts when an output commitment collides with an existing tree element), then insert it via `ROLLUP.add(commitment)`. +19. Compute `txnHash` from the proof and public inputs (`keccak256(abi.encode(verificationKeyHash, proof, publicInputs))`). +20. Verify `_nonceHashToTxnHash[nonceHash]` is empty (reverts when another transaction has already claimed this `nonce_hash` slot), then store `= txnHash`. +21. Store: `_commitmentToTxnHash[commitment] = txnHash` for each non-zero output commitment +22. Store transaction data under `_txnHashToData[txnHash]`: `verificationKeyHash`, `senderEncryptedNote`, `senderChainEncryptedKey`, and `userEncryptedKey` are always populated; `recipientEncryptedNote`, `recipientChainEncryptedKey`, and `recipientEncryptedKey` are populated only for `transfer_send`; `memo` is populated only when non-zero. Unwritten fields read back as zero. +23. If `recipient_encrypted_key_hash != 0`, emit `ExternalTransfer(bytes6(uint48(receive_prefix)), txnHash)` + + + +Padding-slot nullifiers must be circuit-constrained to `0` before this flow runs. The first-note `mint` branch constrains `input_nullifier == 0`, and the first-note `transfer_claim` branch constrains `input_nullifier_0 == 0`, so the bridge cannot be used to insert arbitrary non-note elements via padding inputs. + + + +24. For burns: transfer `burn_value` of the token at `token` address to `burn_recipient` via a SafeERC20-style helper (_safeTransfer) that requires `token.code.length > 0` and tolerates both returning and non-returning ERC-20 implementations. + + + +25. For mints: pull `mint_value` from `mint_from` via _pullExactTokens, which wraps the same SafeERC20-style helper and additionally measures the bridge's own token balance before and after. Reverts if the bridge's token-balance delta is not exactly `mint_value` — this rejects fee-on-transfer / transfer-tax tokens, whose actual received amount would be less than `mint_value`. + + + +`_pullExactTokens` rejects fee-on-transfer / transfer-tax tokens, which would otherwise over-credit the privacy layer relative to assets actually received. + + + +For `mint`, `msg.sender` is the public EVM funding account chosen by the app / wallet submission flow. The proof binds that account via `mint_from`, and the bridge verifies `mint_from == msg.sender` before pulling ERC-20 funds. + + + +The bridge is not hardcoded to a single pool token. It uses the token address carried in the verified public inputs for both `mint` and `burn`. + + + +Compatibility is narrower than "any ERC-20" in the strong sense: + +- `mint` rejects fee-on-transfer / transfer-tax tokens because `_pullExactTokens` requires the bridge's balance delta to equal `mint_value` exactly +- `burn` uses `_safeTransfer` and assumes `transfer()` delivers the full `burn_value`; it does not enforce recipient-side exact delivery for taxed tokens + + +### Privacy Proof Verify Precompile + + +The `privacy_proof_verify` precompile handles proof verification for all four circuit variants: + +- Each variant is registered with its verification key hash, category, and a shared `CANONICAL_PUBLIC_INPUTS_LEN = 33` +- All four variants use the same public input ordering, so any contract can decode fields directly from `publicInputs` without variant-specific branching +- The precompile only needs to verify the proof against the registered variant and return the variant identifier to the caller (encoded as a single byte in the rightmost slot of the 32-byte output word) +- Zero-padding safety moves into the circuits themselves: any slot that is not semantically used by a variant must be constrained to `0` + + + +`PRIVACY_PROOF_VERIFY_GAS` is currently set to `0`. See [Deployment Notes](#deployment-notes) — this is a testnet placeholder that must be replaced with a real gas schedule before mainnet, otherwise the precompile is a DoS vector. + + +--- + +## Circuit-to-Sequencer Integration + +### Verification Key Registration + + +Each circuit variant produces a distinct verification key. The `privacy_proof_verify` precompile maintains a registry: + +```rust +struct VerifierConfig { + kind: u8, // Variant id: TRANSFER_SEND, BURN, MINT, TRANSFER_CLAIM + verification_key_hash: [u8; 32], + verification_key: &'static [u8], + oracle_hash_keccak: bool, +} + +const CANONICAL_PUBLIC_INPUTS_LEN: usize = 33; // Shared by all variants post-canonicalization +``` + + +### Fixture Generation + + +After modifying any `.nr` circuit files: + +1. Run `nargo test` to verify circuit correctness +2. Run `noir/generate_fixtures.sh` to regenerate verification keys and compiled programs +3. The generated fixtures are embedded in `pkg/zk-circuits` via the existing macro system + + +### Shared Code (noir/common) + + +The following low-level modules from `noir/common/` are reused: + +- `merkle_path.nr` — Merkle path validation (unchanged) +- `field_utils.nr` — Field utility functions +- `bytes.nr` — Byte manipulation + + + +EVM-specific shared modules live under `noir/evm/common/`: + +- `EvmNote` and note commitment helpers +- Nullifier computation +- `nonce_hash` computation +- `tx_commitment` helpers +- Encryption utilities (Poseidon stream cipher, embedded-curve chain-key encryption) +- Schnorr signature verification helpers + + +### New Circuit Location + + +All EVM circuits live under `noir/evm/`: + +``` +noir/evm/ +├── common/ # Shared EVM note / auth / encryption code +├── mint/ # Mint with Schnorr owner auth +├── burn/ # Burn with Schnorr owner auth +├── transfer_send/ # Send with Schnorr owner auth +├── transfer_claim/ # Claim with 2 Schnorr owner auth checks +``` + + +--- + +## Deployment Notes + + +The current `payy-evm` deployment ships with three values that are acceptable on testnet but must change before mainnet. They are enumerated here — and in [\_meta/changelog.md](_meta/changelog.md) — rather than hidden so that integrators and reviewers can see the gap at a glance. + + + +`GOVERNANCE` is a Solidity compile-time `constant`, currently set to `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` (Anvil/Hardhat account #0) in `PrivacyBridge.sol`. It authorizes `updateChainPublicKey` and cannot be changed without redeploying the contract. + + + +A production deployment must either make `GOVERNANCE` an `immutable` set in the constructor or read it from genesis-configurable storage, and the value itself must be a real governance address — not the test key. + + + +`PRIVACY_PROOF_VERIFY_GAS = 0` in the precompile. Charging zero gas for ZK-proof verification invites DoS. + + + +Mainnet needs a real `PRIVACY_PROOF_VERIFY_GAS` schedule that reflects the cost of running the barretenberg verifier on unbounded input. + diff --git a/docs/specs/privacy-protocol/data.mdx b/docs/specs/privacy-protocol/data.mdx new file mode 100644 index 0000000..84a56a2 --- /dev/null +++ b/docs/specs/privacy-protocol/data.mdx @@ -0,0 +1,217 @@ +--- +depends_on: + - lookup.mdx + - wallet.mdx +impl: + - noir/evm/common/src + - noir/evm/burn + - noir/evm/mint + - pkg/zk-primitives/src/evm + - pkg/payy-evm/tests/common + - pkg/payy-evm/tests/bbjs +--- + +## Note Model + +### Note Structure + + +A note carries six fields — all field elements — that together define its kind, token, position in the owner's chain, entropy, ownership, and balance: + +``` +Note { + kind: Field, // 0 for padding; 1 for the current live-note kind + token: Field, // ERC-20 contract address (as field element) + nonce: Field, // Standard wallet-chain position; direct-send recipient notes start at 0 + psi: Field, // Random entropy (prevents brute-force, ensures unique commitments) + owner: Field, // Owner hash derived from a Grumpkin public key + value: Field, // Token balance (max 240 bits) +} +``` + + +### Field Definitions + + +| Field | Description | +|-------|-------------| +| `kind` | Version flag for future-proofing. A padding note uses `kind = 0`. Any non-padding note is runtime-constrained to `kind = 1` by `note_commitment` and the current circuit variants, so new live kinds require new circuit variants and are rejected by the current ones. | +| `token` | The ERC-20 contract address represented as a field element. Identifies which token this note holds. | +| `nonce` | Position marker for the wallet's standard `(owner, token)` chain. Standard wallet notes start at `0` and increment by `1` on each spend. Recipient-owned notes created by `transfer_send` use `nonce = 0` and are merged into the wallet chain later via `transfer_claim`. | +| `psi` | Random entropy generated per note. Ensures unique commitments even for identical (owner, token, value) combinations. Also used in the nonce_hash lookup chain (see [lookup.mdx](lookup.mdx)) and nullifier derivation. Wallet implementations may choose to reuse psi across transactions for faster lookups (see [lookup.mdx](lookup.mdx)). | +| `owner` | Identity field. This is always `Poseidon(x, y)` over a Grumpkin public key point (see Owner Derivation). Cross-user sends start from the recipient's shared private address, decode it to the public-key point, derive the recipient owner hash from that point, and write that hash here. | +| `value` | Token balance. Constrained to 240 bits maximum to prevent field overflow on addition (`2 × 2^240 < field_modulus`). | + + + +`value` is constrained to 240 bits in every circuit variant so that additions of two note values cannot overflow the BN254 scalar field (`2 × 2^240 < field_modulus`). + + +### Padding Notes + + +A padding note has all six fields (`kind`, `token`, `nonce`, `psi`, `owner`, `value`) set to `0`. Its commitment is defined as `0` (the zero field element). + + + +The `note_commitment` helper rejects any `kind == 0` note with a non-zero field, so the all-zero shape is enforced by construction whenever a circuit invokes `note_commitment` on an input. + + + +Padding notes are used to fill unused input/output slots in fixed-size circuits. + + +### Zero-Value Notes + + +A zero-value note has `kind = 1` and `value = 0`. Unlike padding notes, zero-value notes have a real non-zero commitment (Poseidon hash of all fields) and exist in the Merkle tree. + + + +Zero-value notes are produced when a burn withdraws the entire balance — the change note has zero value but maintains the nonce chain. + + + +A zero-value note can be spent later via `mint` to receive new funds without breaking the nonce sequence. + + +### Cross-User Receive Notes + + +The canonical cross-user flow is a recipient-owned incoming note. `transfer_send` creates `output_note_recv` directly under the recipient owner hash, which the sender derives from the recipient's off-chain shared private address. The note remains a regular `kind = 1` note and is later merged into the recipient's standard wallet chain via `transfer_claim`. + + + +`transfer_send` creates the recipient-owned incoming note with `nonce = 0`. The sender does not know the recipient's current wallet-chain position, so direct sends do not attempt to continue that chain in place. + + + +The user-facing private address is the compact 32-byte Grumpkin public key rendered as hex when serialized as a string. The note's `owner` field is not that 32-byte encoding; wallets decode the compact key to its public-key point, compute `Poseidon(x, y)`, and place that hash into the note. + + + +The recipient shares a 32-byte compact Grumpkin public key off-chain as their private address. Senders use that key in two ways: derive `output_note_recv.owner` from it for the received note, and encrypt the recipient-visible output to that same public key. The recipient later merges the note into their standard chain with `transfer_claim`. + + + +The protocol does not distinguish recipient-owned incoming notes, standard wallet notes, or optional one-time-key notes at the commitment layer. The distinction is lifecycle and discovery, not note shape. + + +--- + +## Cryptographic Primitives + +### Note Commitment + + +The commitment is a 6-element Poseidon hash of all note fields. Padding notes produce a zero commitment. + +``` +commitment(note) = + if note.kind == 0: + 0 + else: + Poseidon(note.kind, note.token, note.nonce, note.psi, note.owner, note.value) +``` + + +### Nullifier + + +The nullifier binds the spent commitment with the note's `psi` under Poseidon: + +``` +nullifier(note, commitment) = + if commitment == 0: + 0 + else: + Poseidon(commitment, note.psi) +``` + + + +The nullifier is derived from the commitment and `psi`. Since `psi` is private, observers cannot link nullifiers to commitments. Zero commitments produce zero nullifiers (padding). + + +### Owner Derivation + + +The `owner` field is always derived from the decoded Grumpkin public-key point: + +``` +owner = Poseidon(pubkey_x, pubkey_y) +``` + +Off-chain transmission uses the compact 32-byte Grumpkin public key. Wallets and provers deterministically decode that compact form into `(pubkey_x, pubkey_y)` before applying the owner formula. All notes use the same owner formula. The protocol never hashes private keys directly into the `owner` field, and it never stores the compact public-key bytes directly in the note. + + + +Standard wallet notes, direct-send incoming notes, and optional one-time-key notes differ only in how the key material is shared and later spent. + +| Note Type | Key Source | Owner | Spend Authorization | +|-----------|------------|-------|---------------------| +| **Standard** | User's primary Payy wallet keypair | `Poseidon(wallet_pubkey_x, wallet_pubkey_y)` | Schnorr signature over `tx_commitment` | +| **Direct-send incoming** | Recipient's shared private address (compact Grumpkin pubkey) | `Poseidon(recipient_pubkey_x, recipient_pubkey_y)` after decoding the compact key | Recipient Schnorr signature(s) during `transfer_claim` | +| **Optional one-time-key** | One-time keypair generated by an integration | `Poseidon(one_time_pubkey_x, one_time_pubkey_y)` | Schnorr signature over `tx_commitment` using that one-time key | + + +### Signature Structure + + +All spend authorization uses Schnorr on Grumpkin. Off-chain transport may use the compact 32-byte Grumpkin public key, but ownership checks and circuit private inputs use the corresponding decoded affine `x/y` coordinates. + + + +The signature bundle passed to the circuit as private input is: + +``` +OwnerSignature { + signature: [u8; 64], // Barretenberg Schnorr signature bytes (`s || e`) + public_key_x: Field, // Signer's Grumpkin public key x-coordinate + public_key_y: Field, // Signer's Grumpkin public key y-coordinate +} +``` + +Wallet-facing APIs and private-address exchange still use the compact 32-byte public key. The wallet/prover decodes that compact key into affine coordinates before passing this bundle into the circuit. + + + +Wallets produce signatures compatible with this verifier; the canonical off-chain helper is barretenberg's `schnorrConstructSignature`, also exposed as `BarretenbergSync#schnorrConstructSignature()` via `bb.js`. The transport-facing public-key representation remains the compact 32-byte form even though the proving path decodes it into `x/y` before curve verification. + + +### Transaction Commitment (Signed Message) + + +The required owners sign a Poseidon hash of all note commitments and recipient-discovery bindings in the transaction. The hash always takes 12 elements; missing commitments and non-applicable fields are zero-filled: + +``` +tx_commitment_kind = 1 // Static version tag for the tx_commitment layout + +tx_commitment = Poseidon( + tx_commitment_kind, // Domain/version separator for future tx_commitment changes + chain_id, // Prevents cross-chain replay (e.g., after forks) + bridge_address, // Prevents cross-contract replay + input_commitment_0 or 0, // Zero if no input at this slot + input_commitment_1 or 0, // Zero if no input at this slot + output_commitment_0 or 0, // Zero if no output at this slot + output_commitment_1 or 0, // Zero if no output at this slot + burn_recipient or 0, // Zero for non-burn circuits + mint_from or 0, // Zero for non-mint circuits + user_encrypted_key_hash, // Binds sender self-decryption key material to the owner signatures + recipient_encrypted_key_hash, // Zero outside transfer_send; binds recipient key material when present + receive_prefix // Zero outside transfer_send; binds the emitted 6-byte recipient discovery prefix +) +``` + + + +`tx_commitment_kind` is currently hardcoded to `1`; if the tx-commitment layout needs to change in the future, the protocol can introduce a new kind value without colliding with old hashes. + + + +`burn_recipient`, `mint_from`, `recipient_encrypted_key_hash`, and `receive_prefix` are included because they are public bindings not transitively covered by the note commitments. Without `burn_recipient`, a malicious prover could redirect a burn withdrawal to a different address. Without `mint_from`, the proof would not be bound to a specific public ERC-20 funding account. Without `recipient_encrypted_key_hash`, a relayer could substitute recipient decryption material on `transfer_send`. Without `receive_prefix`, a relayer could emit a log under the wrong recipient bucket. `chain_id` and `bridge_address` prevent replay on forks or alternate deployments. Mint values and token addresses are already bound by the commitments (which include the note's value and token fields). + + + +Every required owner signs the raw 32-byte big-endian encoding of the `tx_commitment` field element directly using Schnorr on Grumpkin (no EIP-191 prefix, no keccak wrapping). Which component performs those signatures depends on the integration mode, but it always happens inside the Payy-aware wallet boundary described in [wallet-app-integrations.md](../wallet-app-integrations/wallet-app-integrations.md). + diff --git a/docs/specs/privacy-protocol/encryption.mdx b/docs/specs/privacy-protocol/encryption.mdx new file mode 100644 index 0000000..0d0a034 --- /dev/null +++ b/docs/specs/privacy-protocol/encryption.mdx @@ -0,0 +1,199 @@ +--- +depends_on: + - contract.mdx + - wallet.mdx +impl: + - noir/evm/common/src + - noir/evm/mint + - noir/evm/burn + - noir/evm/transfer_send + - noir/evm/transfer_claim + - pkg/payy-evm/contracts + - pkg/zk-primitives/src/evm +--- + +## In-Circuit Encryption + +### Purpose + + +All note data is encrypted in-circuit and output as public data. This enables: + +1. **On-chain note discovery**: Users find their encrypted notes via `nonce_hash` lookups +2. **Incoming-transfer discovery**: Recipients discover `transfer_send` notes via `{ prefix6, txHash }` logs and decrypt the recipient output from `TxnData` +3. **Compliance access**: The chain key allows a TEE service (Phala) to decrypt every published output note for permissioned actors +4. **Self-access**: Wallets decrypt their own output notes via their Payy-aware wallet boundary + + +### Encrypted Payload + + +Each encrypted payload contains exactly one output note and no version byte. The `verification_key_hash` (stored as unencrypted metadata, see [contract.mdx](contract.mdx)) identifies the circuit variant and therefore which payload slots are expected to be non-zero. Any change to the encryption structure produces a new circuit and a new verification key hash. + + + +``` +plaintext = [ + output_note.token, + output_note.nonce, // kind omitted — hardcoded, known from verification_key_hash + output_note.psi, + output_note.owner, + output_note.value, +] +``` + + + +Each encrypted note payload is always 5 fields. Variants publish up to two output-note payloads: + +| Variant | Fields | Length | +|---------|--------|--------| +| `mint` | 1 sender-visible output note | 5 | +| `burn` | 1 sender-visible output note | 5 | +| `transfer_send` | 1 sender-visible output note + 1 recipient-visible output note | 10 | +| `transfer_claim` | 1 sender-visible output note | 5 | + + + +**Interpreting the layout**: Wallet clients map the `verification_key_hash` to a known circuit variant and then choose the relevant encrypted note slot: + +- `senderEncryptedNote` for the wallet-chain output +- `recipientEncryptedNote` for the direct-send recipient output on `transfer_send` + +No version bytes, padding, or sentinel values are needed inside the decrypted payload itself. + + +### On-Chain Storage + + +`TxnData` stores two fixed encrypted note blobs, each `5 × 32 = 160` bytes: + +- `senderEncryptedNote` +- `recipientEncryptedNote` + +Variants with only one output note zero-fill the recipient blob. + + + +Unused encrypted note blobs and unused chain-encrypted key bundles are literal zeroes, not encrypted zeroes, so unused transfer slots are publicly verifiable as empty and cannot smuggle hidden data. + + + +Wallet clients recover which note blob is meaningful for the selected circuit from `verification_key_hash` and decrypt only the slot relevant to them. + + + +Variable-length encrypted note storage remains a future optimization; the current spec keeps fixed-size note blobs for a simple bridge ABI and deterministic `TxnData` layout. + + +### Symmetric Encryption (In-Circuit) + + +Each output note is encrypted with its own Poseidon-based stream cipher, which is native to the proving field and adds minimal constraints: + +``` +keystream[i] = Poseidon(symmetric_key, i) +ciphertext[i] = plaintext[i] + keystream[i] // Field addition +``` + +Each `symmetric_key` is an ephemeral random field element generated per output note as a private input. + + + +Every output-note `symmetric_key` is range-checked to ≤240 bits in both note encryption and chain-key encryption. This bound is required because chain-key encryption also uses the same field element as the embedded-curve scalar, and the 240-bit limit matches the range used for note values. + + +### Chain Key PKE (In-Circuit, Embedded Curve) + + +Each output-note symmetric key is encrypted with the chain public key using Noir's `std::embedded_curve_ops` helpers. The implementation uses that output's `symmetric_key` itself as the embedded-curve scalar: + +``` +// Chain keypair: +// chain_sk: scalar (held by Phala TEE) +// chain_pk: (x, y) = chain_sk * G (published in PrivacyBridge contract) + +// In-circuit: +scalar = symmetric_key +shared_secret = scalar * chain_pk +ephemeral_pk = scalar * G + +encrypted_symmetric_key = symmetric_key + Poseidon(shared_secret.x, shared_secret.y) +chain_encrypted_key = (ephemeral_pk.x, ephemeral_pk.y, encrypted_symmetric_key) +``` + + + +The circuit proves every published chain-key bundle is correctly formed, so the Phala TEE is guaranteed to be able to decrypt every output note's `symmetric_key`. + + + +The Phala TEE decrypts: + +``` +shared_secret = chain_sk * ephemeral_pk +symmetric_key = encrypted_symmetric_key - Poseidon(shared_secret.x, shared_secret.y) +``` + + +### User Key PKE (Out-of-Circuit, Wallet Side) + + +The Payy-aware wallet boundary separately encrypts the sender-visible output-note symmetric key with the sender's own public key and publishes it as `userEncryptedKey`. + + + +User-key encryption is NOT verified in ZK — if the user encrypts incorrectly, they lose access to their own notes. Integrity against relayer substitution is instead enforced via `user_encrypted_key_hash`. + + + +By Payy convention, the user-side encryption public key should correspond to the same Grumpkin keypair used for note ownership. Compatible wallets should therefore assume the public key used for self-decryption hashes into the wallet's standard owner identity. The compact public key is the user-facing private address; the Poseidon hash is the internal note owner. This is a wallet interoperability convention / Payy standard, not a circuit-enforced or contract-enforced rule. + + + +The concrete PKE construction is still an out-of-circuit wallet choice exposed through [`publicKeyEncrypt`](wallet.mdx) / [`publicKeyDecrypt`](wallet.mdx), but compatible wallets should preserve the identity convention above unless they have an explicit bilateral integration that says otherwise. The encrypted output must fit in 128 bytes (`bytes32[4]`) - this fixed size prevents calldata spam and keeps the bridge interface bounded. + + + +The encrypted user key is submitted as calldata (`bytes32[4]`). A field-safe hash of it +(`user_encrypted_key_hash`) is included as a public input in all circuit variants, constrained to be +non-zero, and included in `tx_commitment` (bound by every required owner signature): + +``` +user_encrypted_key_hash = + uint256(keccak256(abi.encodePacked(userEncryptedKey[0], ..., userEncryptedKey[3]))) + % SNARK_SCALAR_FIELD +``` + + + +The `PrivacyBridge` contract recomputes the same reduction from calldata and verifies it against the proof's public inputs. This prevents both front-running substitution by relayers and poisoning by malicious wallet clients / provers. + + +### Recipient Key PKE (Out-of-Circuit, Wallet Side) + + +The sender encrypts the recipient-visible output-note symmetric key with the recipient's shared private-address public key and publishes it as `recipientEncryptedKey`. Like `userEncryptedKey`, this construction is out-of-circuit and wallet-defined, but it must fit in 128 bytes (`bytes32[4]`). The bridge and logs only ever see the derived owner hash and its 6-byte prefix; the full recipient private address is exchanged off-chain between users. + +`transfer_send` binds this bundle into the proof with: + +```text +recipient_encrypted_key_hash = + uint256(keccak256(abi.encodePacked(recipientEncryptedKey[0], ..., recipientEncryptedKey[3]))) + % SNARK_SCALAR_FIELD +``` + +Variants that do not create a recipient-visible output note constrain `recipient_encrypted_key_hash = 0` and publish an all-zero `recipientEncryptedKey`. + + + +The proof and bridge only bind the submitted `recipientEncryptedKey` bundle by hash. They do not verify that this bundle was actually produced from the same Grumpkin public key whose Poseidon hash became `output_note_recv.owner`. Standard Payy wallets are expected to use that same keypair for recipient-side decryption, but this is a wallet convention rather than a protocol-enforced invariant. + + +### Chain Public Key Management + + +- The chain's embedded-curve public key is stored in the `PrivacyBridge` contract +- The corresponding private key is held in a Phala TEE +- [`updateChainPublicKey`](contract.mdx) is restricted to `GOVERNANCE`; see [contract.mdx](contract.mdx) for the current deployment-address shape + diff --git a/docs/specs/privacy-protocol/lookup.mdx b/docs/specs/privacy-protocol/lookup.mdx new file mode 100644 index 0000000..e8284e0 --- /dev/null +++ b/docs/specs/privacy-protocol/lookup.mdx @@ -0,0 +1,141 @@ +--- +depends_on: + - contract.mdx +impl: + - noir/evm/common/src + - noir/evm/mint + - noir/evm/burn + - noir/evm/transfer_send + - noir/evm/transfer_claim + - pkg/zk-primitives/src/evm + - pkg/payy-evm/contracts +--- + +## Nonce System & Note Lookup + +### Standard Note Chain Convention + + +For standard wallet-owned note chains, the `nonce` field provides sequential ordering: + +- First note: `nonce = 0` +- Each spend increments: `output.nonce = input.nonce + 1` +- Recipient-owned incoming notes created by `transfer_send` use `nonce = 0` and are later merged into the standard chain with `transfer_claim` + + +### Nonce Hash (Verified Lookup Key) + + +The `nonce_hash` is a lookup key for finding encrypted note data on-chain. + + + +`nonce_hash` is computed in-circuit and emitted as a public input, preventing griefing attacks where a malicious user submits a dummy transaction under someone else's `nonce_hash`. + + + +``` +nonce_hash = Poseidon(kind, token, owner, output_nonce, input_psi) +``` + +Where `input_psi` is the psi of the input note being spent (the previous note's psi). For the first note (`nonce = 0`), there is no input note, so `input_psi` uses a deterministic value (see First Note Discovery below). + + + +The contract stores: `nonce_hash → txn_hash → txn_data`. + + +### First Note Discovery + + +For the very first note of an `(owner, token)` pair, there is no input note, so `input_psi = 0` in the `nonce_hash`. This allows the wallet client to compute the first `nonce_hash` without any prior state. + + + +For subsequent notes, the `input_psi` used in the nonce_hash is the previous note's psi — which is only known by decrypting the previous note. This creates a chain of discovery: + +``` +Note 0: nonce_hash = Poseidon(kind, token, owner, 0, 0) // deterministic +Note 1: nonce_hash = Poseidon(kind, token, owner, 1, note_0.psi) // requires decrypting note 0 +Note 2: nonce_hash = Poseidon(kind, token, owner, 2, note_1.psi) // requires decrypting note 1 +``` + + +### Which Note's Nonce Hash + + +Each circuit variant computes the `nonce_hash` for the **wallet-chain output note** (the output note that continues the owner's standard chain). Recipient-owned incoming notes created in `transfer_send` are not indexed by `nonce_hash` — recipients discover them through the bridge's 6-byte prefix event log and then fetch [`getTxnData`](contract.mdx)`(txn_hash)`. + + + +The bridge emits an [`ExternalTransfer`](contract.mdx) recipient-discovery log for every `transfer_send` containing `{ prefix6, txHash }`, where `prefix6` is the first 6 bytes of the canonical big-endian encoding of `output_note_recv.owner`. The prefix is circuit-bound and collisions are expected; wallets must treat it as a bucket key, not as a unique recipient identifier. The discovery surface continues through [`getTxnData`](contract.mdx)`(txHash)`, which returns recipient-side encrypted fields for candidate logs, and through `transfer_claim`, which merges an accepted recipient-owned incoming note into the recipient's standard wallet chain. + + + +The wallet derives that bucket by decoding the user-facing compact public key into `(x, y)`, computing the same `owner = Poseidon(x, y)` used by notes and signatures, and then taking the first 6 bytes of that owner hash. + + +### Psi Rotation + + +The protocol does not constrain how often `psi` changes between notes. Wallet implementations decide their own rotation policy: + +- **Never rotate**: output note reuses input note's psi. Maximum lookup efficiency (binary search across all nonces). Trade-off: an app that learns the psi can compute all future nonce_hashes and track when each note is spent. +- **Always rotate**: output note uses a fresh random psi. Maximum privacy (apps can only detect the next spend). Trade-off: lookup requires sequential decryption of every note. +- **Periodic rotation**: rotate psi after privacy-sensitive interactions (e.g., after using an untrusted dApp). Balances efficiency and privacy. + + + +Reverting to a previously used psi is safe — nonce_hashes remain unique because the nonce always increments. + + +### Lookup Flow + + +The basic lookup flow decrypts each note sequentially: + +1. Compute `nonce_hash` for nonce 0 with `input_psi = 0` +2. Query [`getTxnHashByNonceHash`](contract.mdx)`(nonce_hash)` → `txn_hash` +3. Query [`getTxnData`](contract.mdx)`(txn_hash)` → `{ verificationKeyHash, senderEncryptedNote, senderChainEncryptedKey, userEncryptedKey, ... }` +4. Map `verificationKeyHash` → circuit variant → determine the output-note layout +5. Decrypt `userEncryptedKey` using the wallet's private key → recover the sender output-note symmetric key → decrypt `senderEncryptedNote` +6. Extract output note's `psi` → compute `nonce_hash` for the next nonce using this psi as `input_psi` +7. Repeat until no more data found → latest note is the user's current state + + +### Incoming Transfer Discovery + + +Wallets own the recipient-side search and decrypt loop for externally-sent notes: + +1. Start from the wallet's private-address keypair (compact public key + matching private key) +2. Derive the wallet's `prefix6` bucket from that keypair +3. Scan bridge logs for matching `{ prefix6, txHash }` +4. For each matching `txHash`, query [`getTxnData`](contract.mdx)`(txHash)` +5. Under the standard Payy wallet convention, attempt to decrypt `recipientEncryptedKey` with the wallet's private-address private key → if that succeeds, recover the recipient output-note symmetric key +6. Attempt to decrypt `recipientEncryptedNote` +7. Keep decryptions that yield a valid `kind = 1` note whose `owner` matches the wallet; discard the rest as prefix collisions or malformed candidates + +Because `prefix6` is only 48 bits, collisions are expected and non-fatal. Wallets must try candidate decryptions and discard failures locally. + + +### Recommended Lookup Strategy (Optional) + + +When psi is not rotated on every transaction, the following strategy avoids decrypting every intermediate note: + +1. Compute `nonce_hash` for nonce 0 with `input_psi = 0`, look up and decrypt → get `psi` +2. Check if `nonce_hash(nonce + 1, psi)` exists — if not, current note is the latest state +3. If it exists, **binary search** forward: probe `nonce_hash(nonce + N, psi)` for increasing N to find the highest nonce where the hash exists with this `psi` +4. Decrypt the note at the highest found nonce → get the output `psi` +5. If `psi` changed (output psi differs from the psi used in the binary search), repeat from step 2 with the new `psi` +6. If `psi` is unchanged, the decrypted note is the current state + +This reduces lookup from O(n) decryptions to O(k) where k is the number of psi rotations, with O(log n) hash lookups per rotation. For users who rarely rotate psi, this is significantly faster. + + +### Alternative Lookup + + +[`getTxnHashByCommitment`](contract.mdx)`(commitment)` enables lookup by commitment hash when the commitment is already known (for example, from a wallet-local cache or a prior log scan). + diff --git a/docs/specs/privacy-protocol/privacy-protocol.mdx b/docs/specs/privacy-protocol/privacy-protocol.mdx new file mode 100644 index 0000000..dcc0f95 --- /dev/null +++ b/docs/specs/privacy-protocol/privacy-protocol.mdx @@ -0,0 +1,75 @@ +--- +depends_on: + - data.mdx + - circuits.mdx + - encryption.mdx + - lookup.mdx + - contract.mdx + - wallet.mdx + - security.mdx +impl: + - pkg/payy-evm + - noir/evm + - noir/Nargo.toml +--- + +# Payy-EVM Privacy Protocol Specification + +## Overview + + +Payy Network is an L2 ZK-rollup on Ethereum designed around privacy. The privacy layer uses a UTXO-like note model where users hold encrypted balances as note commitments in a sparse Merkle tree. Zero-knowledge proofs enforce value conservation, ownership, and correct encryption without revealing transaction details. + + + +Payy Network is currently in **testnet**. Breaking changes to the privacy protocol, circuit inventory, note ownership model, and wallet integration flow are still acceptable at this stage while the design is being simplified and validated before mainnet. + + +### Design Principles + + +**Wallet-boundary model**: The protocol assumes a Payy-aware wallet boundary that manages a Grumpkin keypair, signs the 32-byte big-endian encoding of `tx_commitment` using Schnorr on Grumpkin, and handles proof generation and note access on the user's behalf. This boundary may live inside the app, a native Payy wallet, an embedded auth wallet, or a compatibility proxy-backed wallet service. External ECDSA-only wallets are insufficient on their own. + + + +**Minimal circuits**: Circuits use conditional logic for padding vs real inputs (e.g., mint and transfer_claim handle both first-time and existing-note cases in a single circuit). The only ownership primitive is Schnorr on Grumpkin, so auth no longer forks the circuit set into ECDSA vs Schnorr variants. + + + +**Protocol-local ownership**: Note ownership is always `Poseidon(x, y)` over a Grumpkin public key point. Wallet and address surfaces use the compact 32-byte Grumpkin public key only for off-chain transmission; wallets and provers decode that compact form into affine `x/y` before deriving `owner` or preparing circuit inputs. EVM addresses remain relevant for token contracts, bridge replay protection, and burn recipients, but not for privacy-note authorization. + + + +**Standard note-chain convention**: Wallet-owned note chains aim to keep one active note per `(owner, token)` with sequential nonces for lookup and state progression. Incoming notes created by `transfer_send` are recipient-owned but live outside that active chain until the recipient merges them with `transfer_claim`; they are created with `nonce = 0`. + + + +**Output-scoped encryption**: Note data is encrypted per output note. The symmetric key for each output is encrypted with a chain public key using Noir's embedded-curve ops (verified in ZK) for compliance, and wallets additionally publish out-of-circuit encrypted key material for the parties that are intended to decrypt that output. `transfer_send` therefore carries separate sender and recipient decryption paths, but the protocol only hash-binds the recipient-side bundle; it does not prove that the bundle was produced from the same keypair whose public key hashed into the recipient owner. + + +### Relationship to Existing System + + +The existing v1 rollup (`pkg/node`, `pkg/aggregator`, `pkg/prover`, `noir/utxo`, `noir/agg_*`) remains unchanged and in production. + + + +This spec defines the EVM privacy circuits for `payy-evm`: + +- New circuits live in `noir/evm/` +- Shared low-level libraries (`noir/common/`, `pkg/zk-primitives/`, `pkg/zk-circuits/`) are reused where possible, but EVM-specific note / auth / encryption helpers should live under `noir/evm/common/` so they do not blur with other protocol models +- Services are always distinct between v1 and EVM +- The `noir/evm/erc20_transfer` circuit is retained as-is but is not part of this spec + + +--- + +## Map + +- [data.mdx](data.mdx) — Note model and cryptographic primitives +- [circuits.mdx](circuits.mdx) — Authentication model and circuit variants +- [encryption.mdx](encryption.mdx) — In-circuit encryption +- [lookup.mdx](lookup.mdx) — Nonce system and note lookup +- [contract.mdx](contract.mdx) — Merkle tree, PrivacyBridge contract, sequencer integration +- [wallet.mdx](wallet.mdx) — Wallet interaction +- [security.mdx](security.mdx) — Security properties diff --git a/docs/specs/privacy-protocol/security.mdx b/docs/specs/privacy-protocol/security.mdx new file mode 100644 index 0000000..1d847bb --- /dev/null +++ b/docs/specs/privacy-protocol/security.mdx @@ -0,0 +1,75 @@ +--- +depends_on: + - contract.mdx +--- + +## Security Properties + +### Value Conservation + + +Every circuit variant enforces: no value is created or destroyed (except mint_value from EVM deposits and burn_value to EVM withdrawals). Output values fit in 240 bits to prevent field overflow. + + +### Double-Spend Prevention + + +Nullifiers are checked for uniqueness on-chain. Each note can only be spent once. The nullifier construction (`Poseidon(commitment, psi)`) is unlinkable — observers cannot determine which commitment was spent. + + +### Replay Protection + + +- `recent_root` binds the proof to a specific Merkle tree state +- Nullifiers prevent reuse of the same proof +- `chain_id` is a public input verified against `block.chainid` — prevents cross-chain replay (e.g., after forks) +- `bridge_address` is a public input verified against `address(this)` — prevents cross-contract replay +- Both are included in `tx_commitment` and bound by the required owner signatures + + +### Front-Running Protection + + +- Burn recipient is bound via `burn_recipient_private == burn_recipient_public` — the recipient address is committed in the proof and cannot be modified +- The tx_commitment signatures bind every spending owner to the exact set of commitments + + +### Address-Field Integrity + + +[`_fieldToAddress`](contract.mdx) on the bridge reverts with [`AddressFieldOverflow`](contract.mdx)`(fieldWord)` if any field word used for an EVM address (`token`, `mint_from`, `burn_recipient`) exceeds `type(uint160).max`. + + + +Without this check, a prover could set high bits on the field word and have Solidity silently truncate to a *different* 20-byte address, substituting the token contract, funding account, or withdrawal recipient relative to what the circuit verified. + + +### Encryption Integrity + + +- Chain key encryption is verified in ZK for every published output-note payload — the Phala TEE is guaranteed to be able to decrypt both sender and recipient outputs when present +- Sender and recipient wallet key encryption are NOT verified in ZK — wallets are responsible for encrypting correctly +- `user_encrypted_key_hash` and `recipient_encrypted_key_hash` bind the published wallet key bundles into the proof, preventing relayer substitution + + +### Privacy Guarantees + + +Note values, owners, and token types are hidden inside the encrypted output-note payloads — except `token` and `value` are public for mint/burn because the bridge must perform on-chain ERC-20 transfers, while transfer circuits constrain those slots to zero. + + + +Nullifiers cannot be linked to commitments. The Merkle tree stores both commitments and nullifiers as opaque hashes. + + + +The `nonce_hash` lookup key does not reveal the owner or token (derived from private data). + + + +`transfer_send` cryptographically binds the recipient note owner through `output_note_recv.owner`, binds the discovery bucket through `receive_prefix`, and separately binds the submitted `recipientEncryptedKey` bundle by hash through `recipient_encrypted_key_hash`. The protocol does **not** prove that the submitted `recipientEncryptedKey` was actually constructed from the same private-address public key whose hash became `output_note_recv.owner`; that mapping remains a wallet interoperability convention layered on top of the proof. + + + +Recipient discovery is now a protocol concern: `transfer_send` emits a circuit-bound `{ prefix6, txHash }` log, where `prefix6` is the first 6 bytes of the recipient owner hash. This leaks a stable 48-bit bucket identifier for incoming transfers, but not the full private address exchanged off-chain between users. The circuit also requires `output_note_recv.value > 0`, so funded senders cannot publish zero-value recipient candidates under arbitrary prefix buckets while keeping the full balance in their own continuation note. Optional `memo` data is stored as a single unauthenticated `bytes32` word in `TxnData`; clients may interpret that word as plain or encrypted application data, but the proof does not authenticate its contents. + diff --git a/docs/specs/privacy-protocol/wallet.mdx b/docs/specs/privacy-protocol/wallet.mdx new file mode 100644 index 0000000..4f5d489 --- /dev/null +++ b/docs/specs/privacy-protocol/wallet.mdx @@ -0,0 +1,79 @@ +--- +impl: + - pkg/zk-primitives/src/evm + - pkg/payy-evm/tests/common + - pkg/payy-evm/tests/bbjs +--- + +## Wallet Interaction + +### Transaction Flow + + +When the Payy-aware wallet boundary is exposed through a provider / RPC interface, the expected wallet-facing method surface is the extended `eth_*` API defined in [wallet-apis.md](../wallet-app-integrations/wallet-apis.md). + + + +``` +1. The Payy-aware wallet boundary determines the operation (mint/burn/transfer) +2. It selects the circuit variant based on the inputs +3. It constructs private inputs (notes, Merkle paths, owner signatures, encryption keys) +4. It computes the versioned `tx_commitment` using `tx_commitment_kind = 1` and the public transaction bindings defined in [data.mdx](data.mdx) +5. It signs `tx_commitment` with every required Schnorr owner key +6. It generates the ZK proof using the selected circuit +7. It encrypts each output-note symmetric key for the parties that need it: + - sender/self-access via `user_encrypted_key` + - recipient access via `recipient_encrypted_key` on `transfer_send` +8. For `transfer_send`, it also derives `receive_prefix = first6bytes(owner_be_bytes(output_note_recv.owner))` and prepares any optional opaque `bytes32` memo word (plain or encrypted) +9. For `mint` / `burn`, it returns proof artifacts / bridge calldata to the app for normal Ethereum transaction submission +10. For `transfer_send`, it may instead use the dedicated private-transfer command that owns submission and recipient-address handling end-to-end +``` + + +### Wallet Primitives Required + + +**Schnorr signing** — sign the 32-byte big-endian encoding of `tx_commitment` using Schnorr on Grumpkin (compatible with Noir stdlib `std::schnorr::verify_signature`; canonical off-chain helper is barretenberg `schnorrConstructSignature` / `BarretenbergSync#schnorrConstructSignature()`). + + + +**Public key export** — expose and share the compact 32-byte Grumpkin public key as the canonical user-facing private address. Wallet internals then decode that key to affine `x/y`, derive `owner = Poseidon(x, y)`, derive recipient-discovery prefixes from that owner hash, and pass `x/y` into circuit inputs. + + + +**User PKE identity convention** — for self-decryption and direct-send receipt, compatible wallets should treat the public key used for `publicKeyEncrypt` / `publicKeyDecrypt` as belonging to the same Grumpkin keypair that defines the wallet's owner identity, unless an integration explicitly documents a different mapping. The protocol does not prove this mapping for `recipientEncryptedKey`; wallets still need successful decryption and owner checks on the recovered note before accepting an incoming candidate as theirs. + + + +**Recipient-address sharing** — wallets must be able to share the compact 32-byte Grumpkin public key used as the recipient's private address. Other users consume that key off-chain, derive the recipient owner hash from it, and encrypt the recipient-side key material for `transfer_send`. + + + +**Public-key encryption / decryption**: +- publicKeyEncrypt — encrypt data with a public key +- publicKeyDecrypt — decrypt data with the wallet's key + +Wallets need these primitives to: + +- produce sender-side self-decryption material for `userEncryptedKey` +- produce recipient-side decryption material for `recipientEncryptedKey` on `transfer_send` +- decrypt sender-side and recipient-side note data during normal wallet lookup / receive flows + +The concrete PKE construction remains wallet-defined and out-of-circuit, but these primitives are part of the active wallet contract rather than an optional future extension. Depending on the integration mode, they may live inside the combined app, a native Payy wallet, an embedded auth wallet, or a compatibility proxy-backed wallet service. + + +### Integration Placement + + +The exact component boundary depends on the integration mode described in [wallet-app-integrations.md](../wallet-app-integrations/wallet-app-integrations.md). Regardless of mode, the Payy-aware wallet boundary: + +- holds or derives the user's Grumpkin keypair +- decrypts notes and fetches Merkle paths +- produces the required Schnorr signatures for each transaction +- generates the ZK proof and the bridge call data used by the app's `mint` / `burn` transaction +- may also execute `transfer_send` end-to-end when using the dedicated private-transfer command + + + +Apps do not need direct access to raw note data, Merkle paths, or signing keys unless they intentionally implement the combined app + wallet mode. + diff --git a/pkg/beam-cli/Cargo.toml b/pkg/beam-cli/Cargo.toml index e707811..77eb1d1 100644 --- a/pkg/beam-cli/Cargo.toml +++ b/pkg/beam-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "beam-cli" -version = "0.1.1" +version = "0.2.0" edition = "2024" publish = false @@ -37,9 +37,13 @@ serde_json = { workspace = true } serde_yaml = { workspace = true } shlex = { workspace = true } sha2 = { workspace = true } +sourcify-client-reqwest = { workspace = true } +sourcify-interface = { workspace = true } thiserror = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } +url = { workspace = true } +wasmi = { workspace = true } web3 = { workspace = true } workspace-hack.workspace = true diff --git a/pkg/beam-cli/README.md b/pkg/beam-cli/README.md index bc09a03..ffcca9a 100644 --- a/pkg/beam-cli/README.md +++ b/pkg/beam-cli/README.md @@ -75,6 +75,14 @@ Send native gas token: beam --chain sepolia --from alice transfer 0x1111111111111111111111111111111111111111 0.01 ``` +Estimate gas without signing or submitting a transaction: + +```bash +beam --chain sepolia --from alice gas transfer 0x1111111111111111111111111111111111111111 0.01 +beam --chain base --from alice gas erc20 transfer USDC 0x1111111111111111111111111111111111111111 1.5 +beam --chain base --from alice gas send 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 "transfer(address,uint256)" 0x1111111111111111111111111111111111111111 1000000 +``` + Check an ERC20 balance: ```bash @@ -104,6 +112,24 @@ beam txn 0xabc123... beam block latest ``` +Inspect deployed contracts: + +```bash +beam --chain ethereum contract info 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 +beam --chain ethereum contract bytecode 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 --block latest +beam --chain ethereum contract abi 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 +beam --chain ethereum contract source 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 FiatTokenProxy.sol +beam --chain ethereum contract export 0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 ./usdc-source +``` + +Contract inspection accepts only literal `0x` EVM addresses. `bytecode` reads runtime bytecode from +the active RPC after verifying the RPC chain id; `abi`, `source`, and `export` read runtime-verified +artifacts from Sourcify without explorer API keys. Proxy information is reported as a tip when +Sourcify provides it, but Beam always inspects the exact address you passed. Artifact stdout is +pipeable: `bytecode`, `abi`, and `source
` print only the requested artifact +in default and compact modes. The bytecode command has no `code` alias. Use `--` before a +`source-path` or `destination` value that begins with `-`. + Start the interactive REPL: ```bash @@ -194,6 +220,75 @@ beam --from alice --chain payy-testnet fetch --private-payment https://paywall.e beam fetch -v -L https://api.example.com/redirect ``` +## Apps + +Beam apps are Payy-controlled WASM extensions installed from the Beam registry at +`https://registry.beam.payy.network`. Beam verifies registry signatures and SHA-256 +digests before caching app artifacts under `~/.beam/apps`. + +Common commands: + +```bash +beam apps install uniswap +beam apps list +beam apps info uniswap +beam apps permissions uniswap +beam apps update uniswap +beam apps remove uniswap +``` + +Install shows the app publisher, version, registry source, WASM digest, HTTP origins, +chain scopes, contract scopes, function selectors, spender scopes, wallet capabilities, +storage permissions, and privacy capabilities before asking for approval. Use +`--dry-run` to show the same permission summary without activating the app: + +```bash +beam apps install uniswap --dry-run --format json +``` + +Run app commands with the short `x` alias or the explicit lifecycle form: + +```bash +beam x uniswap --help +``` + +Product app business logic lives outside Beam CLI in `beam-apps/apps/`. +Beam CLI owns the generic registry, cache, WASM validation, permission checks, +approval records, and execution of approved action plans. The Uniswap app is +built into the registry as WASM, but `beam x uniswap swap ...` remains behind the +generic guest host-ABI invocation milestone; Beam CLI no longer contains a +Uniswap-specific built-in planner. + +The Uniswap app will use Beam-mediated HTTPS requests to the Uniswap Trading +API. Release registry builds inject the Payy-managed public Trading API key into +the app artifact from CI: + +```bash +export BEAM_UNISWAP_PUBLIC_API_KEY=... +``` + +The built artifact contains the key, so it is public, rotatable product +configuration rather than a user secret. + +For tests and controlled deployments, a registry app manifest can declare a +compatible mocked Trading API endpoint. Beam still enforces the installed app's +declared HTTPS permissions, redirect containment, response limits, chain scopes, +selectors, and spender scopes. + +Wallet-affecting app actions are approved by Beam, not by the app. Agents and other +non-interactive callers should prepare a continuation, inspect it, then explicitly +approve and execute it: + +```bash +beam --chain base --from alice x --prepare --format json +beam apps approvals show +beam apps approvals approve --execute +``` + +`--no-prompt` fails closed for wallet-affecting app commands unless the command is +preparing a continuation. Removing an app keeps app-local data by default; pass +`--purge-data` to delete `~/.beam/apps/data/` as well. + ## Privacy Beam privacy support is configured per chain. Built-in Payy privacy-capable chains include a @@ -255,7 +350,7 @@ Notes: - Use `--private-key-stdin` for pipelines and `--private-key-fd ` for redirected file descriptors. - `beam wallets create` prompts for a wallet name when you omit `[name]`, suggesting the next available `wallet-N` alias and accepting it when you press Enter. - `beam wallets import` uses a verified ENS reverse record as the default wallet name when one resolves back to the imported address; otherwise it falls back to the next `wallet-N` alias. -- The CLI prompts for a password when creating/importing a wallet and rejects empty or whitespace-only values. +- The CLI prompts for a password when creating/importing a wallet. Press Enter at the password prompt to create a wallet with no password; whitespace-only passwords are rejected. - Beam trims surrounding whitespace and sanitizes terminal control characters in wallet names, rejecting aliases that become empty after normalization. - Commands that need signing prompt for the keystore password again before decrypting. - `beam privacy address` uses the same password prompt and keystore integrity checks before @@ -452,6 +547,11 @@ beam erc20 transfer beam erc20 approve beam call [args...] beam send [--value ] [args...] +beam contract info
+beam contract bytecode
[--block ] +beam contract abi
+beam contract source
[source-path] +beam contract export
beam privacy address beam privacy balance [token|token-address] beam privacy incoming list [--from-block ] [--to-block ] [--include-spent] @@ -539,9 +639,9 @@ removed selectors fall back cleanly instead of killing the session. If you later chains, Beam falls back to the newly selected chain's configured RPC unless you also choose another RPC for that chain. The `help` shortcut prints the full CLI help text plus the REPL-only `exit` command, and both tab completion and inline suggestions follow the same -command tree while also surfacing matching history values. When you have typed part of a -command, `Up` / `Down` search only history entries with that prefix; on an empty prompt they -cycle through previously submitted commands. +command tree while also surfacing matching history values. On an empty prompt, `Up` / `Down` +cycle through previously submitted commands. When you type part of a command before pressing +an arrow key, `Up` / `Down` search only history entries with that typed prefix. The `balance` shortcut prints the full tracked-token report for the current session owner, and the regular CLI form still handles one-off selectors such as `balance USDC` or `tokens add ...`. Privacy commands use the regular CLI form under `privacy ...`; tab completion surfaces the privacy @@ -682,20 +782,27 @@ publishes the public `beam-v` GitHub Release assets that the installer `install.beam.payy.network` should serve `scripts/install-beam.sh` as the public installer entrypoint. -One straightforward setup is: +Production serving is owned by the Cloudflare Worker in +`infrastructure/cloudflare/beam-installer`. The Worker embeds +`scripts/install-beam.sh` at deploy time and serves it from `/`, `/install.sh`, +and `/install-beam.sh`. -1. Publish `scripts/install-beam.sh` to a static host such as GitHub Pages. -2. Configure the host to serve the script at `/`. -3. Point the `install.beam.payy.network` DNS record at that static host. -4. Keep the script in sync with the current public GitHub Releases asset naming scheme. +The deploy workflow is `.github/workflows/beam-installer.release.yml`. It runs on merges +to `main` that touch the installer script, the Worker, or its workflow, and publishes with +Wrangler using the `CLOUDFLARE_API_TOKEN` GitHub secret. The Cloudflare account and zone +ids are configured in the Worker's `wrangler.jsonc`. + +After deployment, verify that the public host still serves the canonical script: + +```bash +curl -fsSL https://install.beam.payy.network | shasum -a 256 +shasum -a 256 scripts/install-beam.sh +``` The release workflow lives in the internal repo but is mirrored into `polybase/payy` via Copybara so the public repo can publish the assets that `beam update` and the installer consume. -If you use GitHub Pages, a simple `CNAME` record from `install.beam.payy.network` to the -Pages host is enough as long as the root URL responds with the installer script body. - ## Development From the repository root: diff --git a/pkg/beam-cli/src/apps/approvals.rs b/pkg/beam-cli/src/apps/approvals.rs new file mode 100644 index 0000000..e0f9a85 --- /dev/null +++ b/pkg/beam-cli/src/apps/approvals.rs @@ -0,0 +1,187 @@ +use std::path::Path; + +use contextful::ResultContextExt; +use json_store::{FileAccess, InvalidJsonBehavior, JsonStore}; +use sha2::{Digest, Sha256}; + +use crate::apps::{ + Error, Result, + model::{ActionPlan, ApprovalRecord, ApprovalStatus, ApprovalsState}, + store::now, +}; + +pub struct ApprovalStore { + store: JsonStore, +} + +impl ApprovalStore { + pub async fn load(root: &Path) -> Result { + let store = JsonStore::new_with_invalid_json_behavior_and_access( + root.join("apps"), + "approvals.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .context("load beam app approvals")?; + Ok(Self { store }) + } + + pub async fn list(&self) -> Vec { + self.store.get().await.approvals + } + + pub async fn create(&self, plan: ActionPlan) -> Result { + let created_at = now(); + let plan_hash = plan_hash(&plan)?; + let id = format!("apr_{}", &plan_hash["sha256:".len()..18]); + let record = ApprovalRecord { + id, + status: ApprovalStatus::Pending, + plan, + plan_hash, + created_at, + updated_at: created_at, + }; + self.store + .update(|state| { + state.approvals.retain(|approval| approval.id != record.id); + state.approvals.push(record.clone()); + }) + .await + .context("persist beam app approval")?; + + Ok(record) + } + + pub async fn find(&self, id: &str) -> Result { + self.store + .get() + .await + .approvals + .into_iter() + .find(|approval| approval.id == id) + .ok_or_else(|| Error::ApprovalNotFound { + approval_id: id.to_string(), + }) + } + + pub async fn approve(&self, id: &str) -> Result { + let existing = self.find(id).await?; + ensure_approval_pending(&existing)?; + + let mut selected = None; + self.store + .update(|state| { + for approval in &mut state.approvals { + if approval.id == id { + approval.status = ApprovalStatus::Approved; + approval.updated_at = now(); + selected = Some(approval.clone()); + } + } + }) + .await + .context("persist beam app approval status")?; + let approval = selected.ok_or_else(|| Error::ApprovalNotFound { + approval_id: id.to_string(), + })?; + Ok(approval) + } + + pub async fn mark_executed(&self, id: &str) -> Result { + let existing = self.find(id).await?; + ensure_approval_executable(&existing)?; + + let mut selected = None; + self.store + .update(|state| { + for approval in &mut state.approvals { + if approval.id == id { + approval.status = ApprovalStatus::Executed; + approval.updated_at = now(); + selected = Some(approval.clone()); + } + } + }) + .await + .context("persist beam app approval execution")?; + selected.ok_or_else(|| Error::ApprovalNotFound { + approval_id: id.to_string(), + }) + } + + pub async fn reject(&self, id: &str) -> Result { + let mut selected = None; + self.store + .update(|state| { + for approval in &mut state.approvals { + if approval.id == id { + approval.status = ApprovalStatus::Rejected; + approval.updated_at = now(); + selected = Some(approval.clone()); + } + } + }) + .await + .context("persist beam app approval rejection")?; + selected.ok_or_else(|| Error::ApprovalNotFound { + approval_id: id.to_string(), + }) + } +} + +pub fn ensure_approval_pending(record: &ApprovalRecord) -> Result<()> { + ensure_approval_integrity(record)?; + if record.status != ApprovalStatus::Pending { + return Err(Error::ApprovalNotPending { + approval_id: record.id.clone(), + }); + } + + Ok(()) +} + +pub fn ensure_approval_executable(record: &ApprovalRecord) -> Result<()> { + ensure_approval_integrity(record)?; + if !matches!( + record.status, + ApprovalStatus::Pending | ApprovalStatus::Approved + ) { + return Err(Error::ApprovalNotPending { + approval_id: record.id.clone(), + }); + } + + Ok(()) +} + +pub fn ensure_approval_integrity(record: &ApprovalRecord) -> Result<()> { + ensure_approval_active(record)?; + ensure_approval_plan_hash(record) +} + +pub fn ensure_approval_active(record: &ApprovalRecord) -> Result<()> { + if record.plan.expires_at < now() { + return Err(Error::ApprovalExpired { + approval_id: record.id.clone(), + }); + } + + Ok(()) +} + +pub fn ensure_approval_plan_hash(record: &ApprovalRecord) -> Result<()> { + if plan_hash(&record.plan)? != record.plan_hash { + return Err(Error::ApprovalPlanChanged { + approval_id: record.id.clone(), + }); + } + + Ok(()) +} + +pub fn plan_hash(plan: &ActionPlan) -> Result { + let bytes = serde_json::to_vec(plan).context("encode beam app action plan")?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} diff --git a/pkg/beam-cli/src/apps/error.rs b/pkg/beam-cli/src/apps/error.rs new file mode 100644 index 0000000..ea7ff2f --- /dev/null +++ b/pkg/beam-cli/src/apps/error.rs @@ -0,0 +1,130 @@ +use contextful::{FromContextful, InternalError}; + +use crate::apps::model::PrivacyCapability; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error, FromContextful)] +pub enum Error { + #[error("[beam-cli/apps] app not installed: {app}")] + AppNotInstalled { app: String }, + + #[error("[beam-cli/apps] unknown app in registry: {app}")] + RegistryAppNotFound { app: String }, + + #[error("[beam-cli/apps] version not found for {app}: {version}")] + RegistryVersionNotFound { app: String, version: String }, + + #[error("[beam-cli/apps] app id mismatch: expected {expected}, got {actual}")] + AppIdMismatch { actual: String, expected: String }, + + #[error("[beam-cli/apps] app version mismatch: expected {expected}, got {actual}")] + AppVersionMismatch { actual: String, expected: String }, + + #[error("[beam-cli/apps] invalid app id: {value}")] + InvalidAppId { value: String }, + + #[error("[beam-cli/apps] invalid app command: {value}")] + InvalidCommandName { value: String }, + + #[error("[beam-cli/apps] invalid permission glob: {value}")] + InvalidPermissionGlob { value: String }, + + #[error("[beam-cli/apps] invalid permission url: {value}")] + InvalidPermissionUrl { value: String }, + + #[error("[beam-cli/apps] invalid registry url: {value}")] + InvalidRegistryUrl { value: String }, + + #[error("[beam-cli/apps] invalid permission selector: {value}")] + InvalidPermissionSelector { value: String }, + + #[error("[beam-cli/apps] invalid digest for {artifact}: {digest}")] + InvalidDigest { artifact: String, digest: String }, + + #[error("[beam-cli/apps] digest mismatch for {artifact}: expected {expected}, got {actual}")] + DigestMismatch { + actual: String, + artifact: String, + expected: String, + }, + + #[error("[beam-cli/apps] unsupported Beam version for {app}: requires {required}")] + UnsupportedBeamVersion { app: String, required: String }, + + #[error("[beam-cli/apps] registry signature mismatch for {artifact}")] + SignatureMismatch { artifact: String }, + + #[error("[beam-cli/apps] app module is not a wasm module: {app}")] + InvalidWasmModule { app: String }, + + #[error("[beam-cli/apps] app requested blocked contract target: {target}")] + ContractPermissionDenied { target: String }, + + #[error("[beam-cli/apps] app requested blocked chain operation on {chain}: {operation}")] + ChainPermissionDenied { chain: String, operation: String }, + + #[error("[beam-cli/apps] app requested blocked selector: {selector}")] + SelectorPermissionDenied { selector: String }, + + #[error("[beam-cli/apps] app requested blocked approval spender: {spender}")] + SpenderPermissionDenied { spender: String }, + + #[error("[beam-cli/apps] app requested blocked http url: {url}")] + HttpPermissionDenied { url: String }, + + #[error("[beam-cli/apps] app requested unsafe http host: {host}")] + HttpHostDenied { host: String }, + + #[error("[beam-cli/apps] app http request too large: {bytes} bytes")] + HttpRequestTooLarge { bytes: usize }, + + #[error("[beam-cli/apps] app http response too large: {bytes} bytes")] + HttpResponseTooLarge { bytes: usize }, + + #[error("[beam-cli/apps] app http redirect limit exceeded: {url}")] + HttpRedirectLimitExceeded { url: String }, + + #[error("[beam-cli/apps] app host api request is invalid: {reason}")] + InvalidHostRequest { reason: String }, + + #[error("[beam-cli/apps] app action requires approval")] + ApprovalRequired, + + #[error("[beam-cli/apps] app approval rejected")] + ApprovalRejected, + + #[error("[beam-cli/apps] approval not found: {approval_id}")] + ApprovalNotFound { approval_id: String }, + + #[error("[beam-cli/apps] approval expired: {approval_id}")] + ApprovalExpired { approval_id: String }, + + #[error("[beam-cli/apps] approval is not pending: {approval_id}")] + ApprovalNotPending { approval_id: String }, + + #[error("[beam-cli/apps] approval app artifact changed: {approval_id}")] + ApprovalArtifactChanged { approval_id: String }, + + #[error("[beam-cli/apps] approval plan changed: {approval_id}")] + ApprovalPlanChanged { approval_id: String }, + + #[error( + "[beam-cli/apps] approval execution context changed for {approval_id}: {field} expected {expected}, got {actual}" + )] + ApprovalContextChanged { + actual: String, + approval_id: String, + expected: String, + field: String, + }, + + #[error("[beam-cli/apps] unsupported app command: {command}")] + UnsupportedAppCommand { command: String }, + + #[error("[beam-cli/apps] unsupported privacy app capability: {capability:?}")] + UnsupportedPrivacyCapability { capability: PrivacyCapability }, + + #[error("[beam-cli/apps] internal error")] + Internal(#[from] InternalError), +} diff --git a/pkg/beam-cli/src/apps/host.rs b/pkg/beam-cli/src/apps/host.rs new file mode 100644 index 0000000..8401cf4 --- /dev/null +++ b/pkg/beam-cli/src/apps/host.rs @@ -0,0 +1,516 @@ +// lint-long-file-override allow-max-lines=550 +#![expect( + dead_code, + reason = "declares host ABI surface before wasm guest bindings call every API" +)] +use std::{net::IpAddr, time::Duration}; + +use contextful::ResultContextExt; +use contracts::{Address, U256}; +use reqwest::{ + Client, Method, StatusCode, Url, + header::{HeaderMap, HeaderName, HeaderValue, LOCATION}, + redirect::Policy, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use web3::types::{Bytes, CallRequest}; + +use crate::{ + apps::{ + Error, Result, + model::{AppPermissions, ChainOperation}, + permissions::{ensure_chain_scope, glob_matches}, + }, + evm::{erc20_allowance, erc20_balance, native_balance, simulate_calldata}, + runtime::BeamApp, +}; + +const HTTP_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_REDIRECTS: usize = 5; +const MAX_REQUEST_BYTES: usize = 64 * 1024; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum HostRequest { + AppMetadata, + Args { args: Vec }, + StructuredOutput { value: Value }, + Diagnostic { level: String, message: String }, + HttpFetch(HttpFetchRequest), + ChainRead(ChainReadRequest), + SimulateTransaction(HostTransaction), + SubmitTransaction(HostTransaction), + PollReceipt { tx_hash: String }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HttpFetchRequest { + pub method: String, + pub url: String, + #[serde(default)] + pub headers: Vec, + #[serde(default)] + pub body: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HttpHeader { + pub name: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HttpFetchResponse { + pub body: Vec, + pub status: u16, + pub url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChainReadRequest { + pub chain: String, + pub operation: ChainReadOperation, + #[serde(default)] + pub address: Option, + #[serde(default)] + pub data: Option, + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub spender: Option, + pub target: Option, + #[serde(default)] + pub token: Option, + #[serde(default)] + pub value: Option, + pub selector: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ChainReadOperation { + ChainMetadata, + TokenMetadata, + Balance, + Allowance, + Call, + Nonce, + Gas, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostTransaction { + pub chain: String, + pub data: String, + pub target: String, + pub value: String, + pub selector: Option, + pub spender: Option, +} + +pub fn ensure_http_allowed(permissions: &AppPermissions, url: &str) -> Result { + let url = Url::parse(url).map_err(|_| Error::InvalidPermissionUrl { + value: url.to_string(), + })?; + ensure_safe_https_url(&url)?; + if !permissions + .http + .iter() + .any(|permission| glob_matches(&permission.url, url.as_str())) + { + return Err(Error::HttpPermissionDenied { + url: url.to_string(), + }); + } + + Ok(url) +} + +pub async fn fetch_http( + permissions: &AppPermissions, + request: HttpFetchRequest, +) -> Result { + if request.body.len() > MAX_REQUEST_BYTES { + return Err(Error::HttpRequestTooLarge { + bytes: request.body.len(), + }); + } + let client = Client::builder() + .redirect(Policy::none()) + .timeout(HTTP_TIMEOUT) + .build() + .context("build beam app http client")?; + let method = + Method::from_bytes(request.method.as_bytes()).map_err(|_| Error::InvalidHostRequest { + reason: format!("invalid http method {}", request.method), + })?; + let headers = headers(&request.headers)?; + let mut current = ensure_http_allowed(permissions, &request.url)?; + + for _ in 0..=MAX_REDIRECTS { + let mut builder = client + .request(method.clone(), current.clone()) + .headers(headers.clone()); + if !request.body.is_empty() { + builder = builder.body(request.body.clone()); + } + let response = builder + .send() + .await + .context("execute beam app http request")?; + if is_redirect(response.status()) { + let Some(location) = response.headers().get(LOCATION) else { + return Err(Error::InvalidHostRequest { + reason: "redirect missing location header".to_string(), + }); + }; + current = redirect_url(¤t, location)?; + ensure_http_allowed(permissions, current.as_str())?; + continue; + } + + let status = response.status().as_u16(); + let url = current.to_string(); + let bytes = response + .bytes() + .await + .context("read beam app http response")?; + if bytes.len() > MAX_RESPONSE_BYTES { + return Err(Error::HttpResponseTooLarge { bytes: bytes.len() }); + } + + return Ok(HttpFetchResponse { + body: bytes.to_vec(), + status, + url, + }); + } + + Err(Error::HttpRedirectLimitExceeded { + url: current.to_string(), + }) +} + +pub fn ensure_chain_read_allowed( + permissions: &AppPermissions, + request: &ChainReadRequest, +) -> Result<()> { + ensure_chain_scope( + permissions, + &request.chain, + ChainOperation::Read, + request.target.as_deref(), + request.selector.as_deref(), + None, + ) +} + +pub async fn chain_read( + app: &BeamApp, + permissions: &AppPermissions, + request: ChainReadRequest, +) -> Result { + ensure_chain_read_allowed(permissions, &request)?; + let (chain, client) = app + .active_chain_client() + .await + .context("connect beam app chain client")?; + if chain.entry.key != request.chain { + return Err(Error::ChainPermissionDenied { + chain: request.chain, + operation: "read".to_string(), + }); + } + + match request.operation { + ChainReadOperation::ChainMetadata => Ok(json!({ + "chain": chain.entry.key, + "chain_id": chain.entry.chain_id, + "display_name": chain.entry.display_name, + "native_symbol": chain.entry.native_symbol, + })), + ChainReadOperation::TokenMetadata => { + let token = token_input(&request)?; + let resolved = app + .token_for_chain(token, &chain.entry.key) + .await + .context("resolve beam app token metadata")?; + Ok(json!({ + "address": format!("{:#x}", resolved.address), + "decimals": resolved.decimals, + "label": resolved.label, + })) + } + ChainReadOperation::Balance => { + let owner = owner_address(app, request.owner.as_deref()).await?; + let balance = match token_input_optional(&request) { + Some(token) if is_native_token(token, &chain.entry.native_symbol) => { + native_balance(&client, owner) + .await + .context("read beam app native balance")? + } + Some(token) => { + let token = app + .token_for_chain(token, &chain.entry.key) + .await + .context("resolve beam app balance token")?; + erc20_balance(&client, token.address, owner) + .await + .context("read beam app erc20 balance")? + } + None => native_balance(&client, owner) + .await + .context("read beam app native balance")?, + }; + Ok(json!({ "balance": balance.to_string(), "owner": format!("{owner:#x}") })) + } + ChainReadOperation::Allowance => { + let owner = owner_address(app, request.owner.as_deref()).await?; + let spender = required_address("spender", request.spender.as_deref())?; + let token = token_address(app, &chain.entry.key, &request).await?; + let allowance = erc20_allowance(&client, token, owner, spender) + .await + .context("read beam app erc20 allowance")?; + Ok(json!({ + "allowance": allowance.to_string(), + "owner": format!("{owner:#x}"), + "spender": format!("{spender:#x}"), + "token": format!("{token:#x}"), + })) + } + ChainReadOperation::Call => { + let target = required_address("target", request.target.as_deref())?; + let data = parse_hex_data(required("data", request.data.as_deref())?)?; + let from = optional_owner_address(app, request.owner.as_deref()).await?; + let raw = client + .eth_call( + CallRequest { + data: Some(Bytes(data)), + from, + to: Some(target), + value: request.value.as_deref().map(parse_u256).transpose()?, + ..Default::default() + }, + None, + ) + .await + .context("execute beam app chain read call")?; + Ok(json!({ "raw": format!("0x{}", hex::encode(raw.0)) })) + } + ChainReadOperation::Nonce => { + let owner = owner_address(app, request.owner.as_deref()).await?; + let nonce = client.nonce(owner).await.context("fetch beam app nonce")?; + Ok(json!({ "nonce": nonce.to_string(), "owner": format!("{owner:#x}") })) + } + ChainReadOperation::Gas => { + let gas_price = client + .fast_gas_price() + .await + .context("fetch beam app gas price")?; + let estimate = if let (Some(target), Some(data)) = + (request.target.as_deref(), request.data.as_deref()) + { + let from = owner_address(app, request.owner.as_deref()).await?; + Some( + client + .estimate_gas( + CallRequest { + data: Some(Bytes(parse_hex_data(data)?)), + from: Some(from), + to: Some(parse_host_address("target", target)?), + value: request.value.as_deref().map(parse_u256).transpose()?, + ..Default::default() + }, + None, + ) + .await + .context("estimate beam app gas")?, + ) + } else { + None + }; + Ok(json!({ + "gas_price": gas_price.to_string(), + "gas_estimate": estimate.map(|value| value.to_string()), + })) + } + } +} + +pub fn ensure_transaction_allowed( + permissions: &AppPermissions, + transaction: &HostTransaction, + operation: ChainOperation, +) -> Result<()> { + ensure_chain_scope( + permissions, + &transaction.chain, + operation, + Some(&transaction.target), + transaction.selector.as_deref(), + transaction.spender.as_deref(), + ) +} + +pub async fn simulate_transaction( + client: &contracts::Client, + from: Address, + permissions: &AppPermissions, + transaction: &HostTransaction, +) -> Result<()> { + ensure_transaction_allowed(permissions, transaction, ChainOperation::Simulate)?; + simulate_calldata( + client, + from, + parse_host_address("target", &transaction.target)?, + parse_hex_data(&transaction.data)?, + parse_u256(&transaction.value)?, + ) + .await + .context("simulate beam app transaction")?; + + Ok(()) +} + +pub fn ensure_safe_https_url(url: &Url) -> Result<()> { + if url.scheme() != "https" { + return Err(Error::InvalidPermissionUrl { + value: url.to_string(), + }); + } + let host = url.host_str().unwrap_or_default(); + if is_blocked_host(host) { + return Err(Error::HttpHostDenied { + host: host.to_string(), + }); + } + + Ok(()) +} + +fn headers(headers: &[HttpHeader]) -> Result { + let mut output = HeaderMap::new(); + for header in headers { + let name = + HeaderName::from_bytes(header.name.as_bytes()).context("parse app header name")?; + let value = HeaderValue::from_str(&header.value).context("parse app header value")?; + output.insert(name, value); + } + + Ok(output) +} + +fn redirect_url(current: &Url, location: &HeaderValue) -> Result { + let location = location.to_str().context("parse app redirect location")?; + current + .join(location) + .map_err(|_| Error::InvalidHostRequest { + reason: format!("invalid redirect location {location}"), + }) +} + +fn is_redirect(status: StatusCode) -> bool { + matches!( + status, + StatusCode::MOVED_PERMANENTLY + | StatusCode::FOUND + | StatusCode::SEE_OTHER + | StatusCode::TEMPORARY_REDIRECT + | StatusCode::PERMANENT_REDIRECT + ) +} + +fn is_blocked_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") { + return true; + } + match host.parse::() { + Ok(IpAddr::V4(ip)) => { + ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() + } + Ok(IpAddr::V6(ip)) => ip.is_loopback() || ip.is_unspecified() || ip.is_unique_local(), + Err(_) => false, + } +} + +async fn owner_address(app: &BeamApp, value: Option<&str>) -> Result
{ + match value { + Some(value) => Ok(app + .resolve_wallet_or_address(value) + .await + .context("resolve beam app owner address")?), + None => Ok(app + .active_address() + .await + .context("resolve beam app wallet")?), + } +} + +async fn optional_owner_address(app: &BeamApp, value: Option<&str>) -> Result> { + match value { + Some(value) => Ok(Some( + app.resolve_wallet_or_address(value) + .await + .context("resolve beam app optional owner address")?, + )), + None => Ok(None), + } +} + +async fn token_address(app: &BeamApp, chain: &str, request: &ChainReadRequest) -> Result
{ + let token = token_input(request)?; + Ok(app + .token_for_chain(token, chain) + .await + .context("resolve beam app token address")? + .address) +} + +fn token_input(request: &ChainReadRequest) -> Result<&str> { + token_input_optional(request).ok_or_else(|| Error::InvalidHostRequest { + reason: "chain read missing token".to_string(), + }) +} + +fn token_input_optional(request: &ChainReadRequest) -> Option<&str> { + request.token.as_deref().or(request.target.as_deref()) +} + +fn required<'a>(field: &str, value: Option<&'a str>) -> Result<&'a str> { + value.ok_or_else(|| Error::InvalidHostRequest { + reason: format!("chain read missing {field}"), + }) +} + +fn required_address(field: &str, value: Option<&str>) -> Result
{ + parse_host_address(field, required(field, value)?) +} + +fn is_native_token(token: &str, native_symbol: &str) -> bool { + token.eq_ignore_ascii_case("native") + || token.eq_ignore_ascii_case(native_symbol) + || token.eq_ignore_ascii_case("0x0000000000000000000000000000000000000000") +} + +fn parse_hex_data(value: &str) -> Result> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| Error::InvalidHostRequest { + reason: format!("invalid hex data {value}"), + }) +} + +fn parse_host_address(field: &str, value: &str) -> Result
{ + value.parse().map_err(|_| Error::InvalidHostRequest { + reason: format!("invalid {field} address {value}"), + }) +} + +fn parse_u256(value: &str) -> Result { + if let Some(value) = value.strip_prefix("0x") { + return Ok(U256::from_str_radix(value, 16).context("parse beam app hex u256")?); + } + Ok(value + .parse::() + .context("parse beam app decimal u256")?) +} diff --git a/pkg/beam-cli/src/apps/mod.rs b/pkg/beam-cli/src/apps/mod.rs new file mode 100644 index 0000000..fcbad00 --- /dev/null +++ b/pkg/beam-cli/src/apps/mod.rs @@ -0,0 +1,12 @@ +pub mod approvals; +mod error; +pub mod host; +pub mod model; +pub mod permissions; +pub mod privacy; +pub mod registry; +pub mod runtime; +pub mod store; +pub mod validate; + +pub use error::{Error, Result}; diff --git a/pkg/beam-cli/src/apps/model.rs b/pkg/beam-cli/src/apps/model.rs new file mode 100644 index 0000000..e30e373 --- /dev/null +++ b/pkg/beam-cli/src/apps/model.rs @@ -0,0 +1,291 @@ +// lint-long-file-override allow-max-lines=300 +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistryIndex { + pub format_version: u32, + pub generated_at: String, + pub apps: Vec, + pub signature: RegistrySignature, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistryApp { + pub id: String, + pub name: String, + pub publisher: String, + pub description: String, + pub versions: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistryVersion { + pub version: String, + pub min_beam_version: String, + pub manifest_url: String, + pub manifest_sha256: String, + pub module_url: String, + pub module_sha256: String, + pub signature: RegistrySignature, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RegistrySignature { + pub algorithm: String, + pub key_id: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppManifest { + pub format_version: u32, + pub id: String, + pub display_name: String, + pub version: String, + pub publisher: String, + pub description: String, + pub min_beam_version: String, + pub wasm: WasmArtifact, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default)] + pub catalog: AppCatalogMetadata, + pub commands: Vec, + #[serde(default)] + pub permissions: AppPermissions, + #[serde(default)] + pub host_api: HostApi, + pub signature: RegistrySignature, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct WasmArtifact { + pub sha256: String, + pub entrypoint: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppIcon { + pub url: String, + pub sha256: String, + pub media_type: String, + pub alt: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppCatalogMetadata { + #[serde(default)] + pub capability_badges: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppCommand { + pub name: String, + pub about: String, + #[serde(default)] + pub usage: String, + #[serde(default)] + pub sensitive_args: Vec, + #[serde(default)] + pub input_schema: Value, + #[serde(default)] + pub output_schema: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docs: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppCommandDocs { + pub summary: String, + pub invocation: String, + #[serde(default)] + pub arguments: Vec, + #[serde(default)] + pub options: Vec, + #[serde(default)] + pub examples: Vec, + #[serde(default)] + pub output_notes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppCommandParameter { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value_name: Option, + pub kind: String, + #[serde(default)] + pub required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(default)] + pub sensitive: bool, + pub description: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppCommandExample { + pub title: String, + pub command: String, + pub description: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppPermissions { + #[serde(default)] + pub http: Vec, + #[serde(default)] + pub chains: Vec, + #[serde(default)] + pub wallet: WalletPermissions, + #[serde(default)] + pub storage: StoragePermission, + #[serde(default)] + pub privacy: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HttpPermission { + pub url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ChainPermission { + pub chain: String, + #[serde(default)] + pub operations: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contracts: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selectors: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spenders: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ChainOperation { + Read, + Simulate, + SendTransaction, + Erc20Approval, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WalletPermissions { + #[serde(default)] + pub read_balances: bool, + #[serde(default)] + pub propose_transactions: bool, + #[serde(default)] + pub erc20_approval: bool, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoragePermission { + #[serde(default)] + pub app_local: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum PrivacyCapability { + PrivateAddress, + PrivateBalance, + PrivateTransfer, + MintProof, + BurnProof, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostApi { + #[serde(default)] + pub privacy_reserved: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct InstalledApp { + pub id: String, + pub active_version: String, + pub manifest_sha256: String, + pub module_sha256: String, + pub installed_at: u64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppsState { + #[serde(default)] + pub installed: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppLock { + pub id: String, + pub version: String, + pub manifest_sha256: String, + pub module_sha256: String, + pub registry_url: String, + pub installed_at: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionPlan { + pub app_id: String, + pub app_version: String, + pub wasm_sha256: String, + pub manifest_sha256: String, + pub command: String, + pub wallet: Option, + pub chain: String, + #[serde(default)] + pub steps: Vec, + #[serde(default)] + pub bindings: Vec, + #[serde(default)] + pub constraints: Vec, + pub expires_at: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionBinding { + pub key: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionStep { + pub kind: String, + pub summary: String, + pub target: Option, + pub selector: Option, + pub spender: Option, + pub value: Option, + #[serde(default)] + pub metadata: Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ApprovalRecord { + pub id: String, + pub status: ApprovalStatus, + pub plan: ActionPlan, + pub plan_hash: String, + pub created_at: u64, + pub updated_at: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ApprovalStatus { + Pending, + Approved, + Rejected, + Executed, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ApprovalsState { + #[serde(default)] + pub approvals: Vec, +} diff --git a/pkg/beam-cli/src/apps/permissions.rs b/pkg/beam-cli/src/apps/permissions.rs new file mode 100644 index 0000000..bff9ab0 --- /dev/null +++ b/pkg/beam-cli/src/apps/permissions.rs @@ -0,0 +1,79 @@ +use crate::apps::{ + Error, Result, + model::{AppPermissions, ChainOperation, ChainPermission}, +}; + +pub fn ensure_chain_scope( + permissions: &AppPermissions, + chain: &str, + operation: ChainOperation, + target: Option<&str>, + selector: Option<&str>, + spender: Option<&str>, +) -> Result<()> { + let scope = chain_scope(permissions, chain, &operation)?; + if let Some(target) = target { + ensure_optional_scope(scope.contracts.as_deref(), target).map_err(|_| { + Error::ContractPermissionDenied { + target: target.to_string(), + } + })?; + } + if let Some(selector) = selector { + ensure_optional_scope(scope.selectors.as_deref(), selector).map_err(|_| { + Error::SelectorPermissionDenied { + selector: selector.to_string(), + } + })?; + } + if let Some(spender) = spender { + ensure_optional_scope(scope.spenders.as_deref(), spender).map_err(|_| { + Error::SpenderPermissionDenied { + spender: spender.to_string(), + } + })?; + } + + Ok(()) +} + +pub fn glob_matches(pattern: &str, value: &str) -> bool { + let pattern = pattern.to_ascii_lowercase(); + let value = value.to_ascii_lowercase(); + if pattern == "*" { + return true; + } + match pattern.split_once('*') { + Some((prefix, suffix)) => value.starts_with(prefix) && value.ends_with(suffix), + None => pattern == value, + } +} + +fn chain_scope<'a>( + permissions: &'a AppPermissions, + chain: &str, + operation: &ChainOperation, +) -> Result<&'a ChainPermission> { + permissions + .chains + .iter() + .find(|permission| { + glob_matches(&permission.chain, chain) + && permission + .operations + .iter() + .any(|candidate| candidate == operation) + }) + .ok_or_else(|| Error::ChainPermissionDenied { + chain: chain.to_string(), + operation: format!("{operation:?}"), + }) +} + +fn ensure_optional_scope(patterns: Option<&[String]>, value: &str) -> std::result::Result<(), ()> { + match patterns { + Some(patterns) if patterns.iter().any(|pattern| glob_matches(pattern, value)) => Ok(()), + Some(_) => Err(()), + None => Ok(()), + } +} diff --git a/pkg/beam-cli/src/apps/privacy.rs b/pkg/beam-cli/src/apps/privacy.rs new file mode 100644 index 0000000..521ee3c --- /dev/null +++ b/pkg/beam-cli/src/apps/privacy.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +use crate::apps::{Error, Result, model::PrivacyCapability}; + +#[expect( + dead_code, + reason = "privacy host API shapes are reserved before wiring" +)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "operation", rename_all = "kebab-case")] +pub enum PrivacyHostRequest { + PrivateAddress, + PrivateBalance { + token: String, + }, + PrivateTransfer { + recipient: String, + token: String, + amount: String, + }, + MintProof { + token: String, + amount: String, + }, + BurnProof { + token: String, + amount: String, + recipient: String, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "kebab-case")] +pub enum PrivacyHostResponse { + Unsupported { capability: PrivacyCapability }, +} + +#[allow( + dead_code, + reason = "privacy host API shapes are reserved before wiring" +)] +pub fn reject_unsupported(capability: PrivacyCapability) -> Result { + Err(Error::UnsupportedPrivacyCapability { capability }) +} diff --git a/pkg/beam-cli/src/apps/registry.rs b/pkg/beam-cli/src/apps/registry.rs new file mode 100644 index 0000000..c99239a --- /dev/null +++ b/pkg/beam-cli/src/apps/registry.rs @@ -0,0 +1,207 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use reqwest::Client; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::apps::{ + Error, Result, + model::{AppManifest, RegistryApp, RegistryIndex, RegistrySignature, RegistryVersion}, + validate::{ensure_beam_version, validate_index, validate_manifest, validate_registry_url}, +}; + +pub const DEFAULT_REGISTRY_URL: &str = "https://registry.beam.payy.network"; + +pub async fn fetch_index(registry_url: &str) -> Result { + validate_registry_url(registry_url)?; + let url = format!("{}/index.json", registry_url.trim_end_matches('/')); + let bytes = Client::new() + .get(&url) + .send() + .await + .context("fetch beam app registry index")? + .error_for_status() + .context("validate beam app registry index response")? + .bytes() + .await + .context("read beam app registry index")?; + let index = serde_json::from_slice::(&bytes) + .context("decode beam app registry index")?; + validate_index(&index, registry_url)?; + verify_signature("index", &index, &index.signature)?; + Ok(index) +} + +pub async fn fetch_manifest(version: &RegistryVersion) -> Result<(AppManifest, Vec)> { + let bytes = fetch_bytes(&version.manifest_url).await?; + ensure_digest("manifest", &bytes, &version.manifest_sha256)?; + let manifest = + serde_json::from_slice::(&bytes).context("decode beam app manifest")?; + validate_manifest(&manifest)?; + ensure_beam_version(&manifest.id, &manifest.min_beam_version)?; + verify_signature("manifest", &manifest, &manifest.signature)?; + Ok((manifest, bytes)) +} + +pub async fn fetch_module(version: &RegistryVersion, manifest: &AppManifest) -> Result> { + let bytes = fetch_bytes(&version.module_url).await?; + ensure_digest("module", &bytes, &version.module_sha256)?; + ensure_digest("wasm", &bytes, &manifest.wasm.sha256)?; + Ok(bytes) +} + +pub fn select_app<'a>(index: &'a RegistryIndex, app_id: &str) -> Result<&'a RegistryApp> { + index + .apps + .iter() + .find(|app| app.id == app_id) + .ok_or_else(|| Error::RegistryAppNotFound { + app: app_id.to_string(), + }) +} + +pub fn select_version<'a>( + app: &'a RegistryApp, + requested: Option<&str>, +) -> Result<&'a RegistryVersion> { + match requested { + Some(version) => app + .versions + .iter() + .find(|candidate| candidate.version == version) + .ok_or_else(|| Error::RegistryVersionNotFound { + app: app.id.clone(), + version: version.to_string(), + }), + None => app + .versions + .iter() + .max_by_key(|candidate| semver_key(&candidate.version)) + .ok_or_else(|| Error::RegistryAppNotFound { + app: app.id.clone(), + }), + } +} + +pub fn ensure_manifest_matches( + app_id: &str, + version: &RegistryVersion, + manifest: &AppManifest, +) -> Result<()> { + if manifest.id != app_id { + return Err(Error::AppIdMismatch { + actual: manifest.id.clone(), + expected: app_id.to_string(), + }); + } + if manifest.version != version.version { + return Err(Error::AppVersionMismatch { + actual: manifest.version.clone(), + expected: version.version.clone(), + }); + } + + Ok(()) +} + +pub fn ensure_digest(artifact: &str, bytes: &[u8], expected: &str) -> Result<()> { + let actual = format!("sha256:{}", hex::encode(Sha256::digest(bytes))); + if actual != expected { + return Err(Error::DigestMismatch { + actual, + artifact: artifact.to_string(), + expected: expected.to_string(), + }); + } + + Ok(()) +} + +pub fn registry_url_from_env() -> String { + std::env::var("BEAM_APP_REGISTRY_URL").unwrap_or_else(|_| DEFAULT_REGISTRY_URL.to_string()) +} + +pub fn signing_digest(value: &T) -> Result { + let mut payload = + serde_json::to_value(value).context("encode beam app signing payload value")?; + blank_top_level_signature(&mut payload); + sort_json_value(&mut payload); + let canonical = serde_json::to_vec(&payload).context("encode beam app signing payload")?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(canonical)))) +} + +fn blank_top_level_signature(value: &mut Value) { + let Some(signature) = value + .as_object_mut() + .and_then(|object| object.get_mut("signature")) + .and_then(Value::as_object_mut) + else { + return; + }; + signature.insert("value".to_string(), Value::String(String::new())); +} + +fn sort_json_value(value: &mut Value) { + match value { + Value::Array(values) => { + for value in values { + sort_json_value(value); + } + } + Value::Object(object) => { + for value in object.values_mut() { + sort_json_value(value); + } + let mut entries = object + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + object.clear(); + for (key, value) in entries { + object.insert(key, value); + } + } + _ => {} + } +} + +fn verify_signature( + artifact: &str, + value: &T, + signature: &RegistrySignature, +) -> Result<()> { + if signature.algorithm != "sha256-dev" { + return Err(Error::SignatureMismatch { + artifact: artifact.to_string(), + }); + } + + let expected = signing_digest(value)?; + if signature.value != expected { + return Err(Error::SignatureMismatch { + artifact: artifact.to_string(), + }); + } + + Ok(()) +} + +async fn fetch_bytes(url: &str) -> Result> { + Ok(Client::new() + .get(url) + .send() + .await + .context("fetch beam app artifact")? + .error_for_status() + .context("validate beam app artifact response")? + .bytes() + .await + .context("read beam app artifact")? + .to_vec()) +} + +fn semver_key(version: &str) -> semver::Version { + semver::Version::parse(version).unwrap_or_else(|_| semver::Version::new(0, 0, 0)) +} diff --git a/pkg/beam-cli/src/apps/runtime.rs b/pkg/beam-cli/src/apps/runtime.rs new file mode 100644 index 0000000..16d3771 --- /dev/null +++ b/pkg/beam-cli/src/apps/runtime.rs @@ -0,0 +1,45 @@ +use std::path::Path; + +use contextful::ResultContextExt; +use wasmi::{Engine, Linker, Module, Store}; + +use crate::apps::{Error, Result}; + +const WASM_MAGIC: &[u8; 4] = b"\0asm"; + +pub fn validate_wasm_module(app_id: &str, entrypoint: &str, path: &Path) -> Result<()> { + let bytes = std::fs::read(path).context("read beam app wasm module")?; + if bytes.len() < 8 || &bytes[..4] != WASM_MAGIC { + return Err(Error::InvalidWasmModule { + app: app_id.to_string(), + }); + } + AppRuntime::default().instantiate(app_id, entrypoint, &bytes)?; + + Ok(()) +} + +#[derive(Default)] +pub struct AppRuntime { + engine: Engine, +} + +impl AppRuntime { + fn instantiate(&self, app_id: &str, entrypoint: &str, bytes: &[u8]) -> Result<()> { + let module = Module::new(&self.engine, bytes).context("compile beam app wasm module")?; + let mut store = Store::new(&self.engine, HostState); + let linker = >::new(&self.engine); + let instance = linker + .instantiate_and_start(&mut store, &module) + .context("instantiate beam app wasm module")?; + if instance.get_func(&store, entrypoint).is_none() { + return Err(Error::InvalidHostRequest { + reason: format!("{app_id} wasm missing entrypoint {entrypoint}"), + }); + } + + Ok(()) + } +} + +struct HostState; diff --git a/pkg/beam-cli/src/apps/store.rs b/pkg/beam-cli/src/apps/store.rs new file mode 100644 index 0000000..69624f7 --- /dev/null +++ b/pkg/beam-cli/src/apps/store.rs @@ -0,0 +1,147 @@ +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use contextful::ResultContextExt; +use json_store::{FileAccess, InvalidJsonBehavior, JsonStore}; + +use crate::apps::{ + Error, Result, + model::{AppLock, AppManifest, AppsState, InstalledApp}, +}; + +pub struct AppCache { + root: PathBuf, + state: JsonStore, +} + +impl AppCache { + pub async fn load(beam_root: &Path) -> Result { + let root = beam_root.join("apps"); + fs::create_dir_all(root.join("apps")).context("create beam app cache")?; + fs::create_dir_all(root.join("data")).context("create beam app data directory")?; + let state = JsonStore::new_with_invalid_json_behavior_and_access( + &root, + "apps-state.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .context("load beam app cache state")?; + + Ok(Self { root, state }) + } + + pub async fn installed(&self) -> AppsState { + self.state.get().await + } + + pub async fn install( + &self, + manifest: &AppManifest, + manifest_bytes: &[u8], + module_bytes: &[u8], + manifest_sha256: &str, + module_sha256: &str, + registry_url: &str, + ) -> Result<()> { + let app_dir = self.version_dir(&manifest.id, &manifest.version); + fs::create_dir_all(&app_dir).context("create beam app version directory")?; + fs::write(app_dir.join("manifest.json"), manifest_bytes) + .context("write beam app manifest")?; + fs::write(app_dir.join("module.wasm"), module_bytes).context("write beam app module")?; + let lock = AppLock { + id: manifest.id.clone(), + version: manifest.version.clone(), + manifest_sha256: manifest_sha256.to_string(), + module_sha256: module_sha256.to_string(), + registry_url: registry_url.to_string(), + installed_at: now(), + }; + fs::write( + app_dir.join("lock.json"), + serde_json::to_vec_pretty(&lock).context("encode beam app lock")?, + ) + .context("write beam app lock")?; + + self.state + .update(|state| { + state.installed.retain(|app| app.id != manifest.id); + state.installed.push(InstalledApp { + id: manifest.id.clone(), + active_version: manifest.version.clone(), + manifest_sha256: manifest_sha256.to_string(), + module_sha256: module_sha256.to_string(), + installed_at: lock.installed_at, + }); + }) + .await + .context("persist beam app state")?; + + Ok(()) + } + + pub async fn remove(&self, app_id: &str, purge_data: bool) -> Result<()> { + self.state + .update(|state| state.installed.retain(|app| app.id != app_id)) + .await + .context("persist beam app removal")?; + let app_dir = self.app_dir(app_id); + if app_dir.exists() { + fs::remove_dir_all(app_dir).context("remove beam app artifacts")?; + } + if purge_data { + let data_dir = self.root.join("data").join(app_id); + if data_dir.exists() { + fs::remove_dir_all(data_dir).context("remove beam app data")?; + } + } + + Ok(()) + } + + pub async fn active_manifest(&self, app_id: &str) -> Result<(InstalledApp, AppManifest)> { + let state = self.state.get().await; + let installed = state + .installed + .into_iter() + .find(|app| app.id == app_id) + .ok_or_else(|| Error::AppNotInstalled { + app: app_id.to_string(), + })?; + let manifest_path = self + .version_dir(app_id, &installed.active_version) + .join("manifest.json"); + let manifest = serde_json::from_slice::( + &fs::read(manifest_path).context("read installed beam app manifest")?, + ) + .context("decode installed beam app manifest")?; + + Ok((installed, manifest)) + } + + pub fn module_path(&self, app_id: &str, version: &str) -> PathBuf { + self.version_dir(app_id, version).join("module.wasm") + } + + pub fn data_dir(&self, app_id: &str) -> PathBuf { + self.root.join("data").join(app_id) + } + + fn app_dir(&self, app_id: &str) -> PathBuf { + self.root.join("apps").join(app_id) + } + + fn version_dir(&self, app_id: &str, version: &str) -> PathBuf { + self.app_dir(app_id).join(version) + } +} + +pub fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} diff --git a/pkg/beam-cli/src/apps/validate.rs b/pkg/beam-cli/src/apps/validate.rs new file mode 100644 index 0000000..08e902b --- /dev/null +++ b/pkg/beam-cli/src/apps/validate.rs @@ -0,0 +1,286 @@ +// lint-long-file-override allow-max-lines=300 +use std::net::IpAddr; + +use semver::Version; +use url::Url; + +use crate::apps::{ + Error, Result, + model::{AppManifest, ChainPermission, RegistryIndex}, +}; + +pub fn validate_index(index: &RegistryIndex, registry_url: &str) -> Result<()> { + validate_registry_url(registry_url)?; + for app in &index.apps { + validate_app_id(&app.id)?; + for version in &app.versions { + validate_digest("manifest", &version.manifest_sha256)?; + validate_digest("module", &version.module_sha256)?; + validate_registry_artifact_url(registry_url, &version.manifest_url)?; + validate_registry_artifact_url(registry_url, &version.module_url)?; + Version::parse(&version.version).map_err(|_| Error::RegistryVersionNotFound { + app: app.id.clone(), + version: version.version.clone(), + })?; + } + } + + Ok(()) +} + +pub fn validate_registry_url(value: &str) -> Result<()> { + parse_registry_url(value).map(|_| ()) +} + +pub fn validate_manifest(manifest: &AppManifest) -> Result<()> { + validate_app_id(&manifest.id)?; + validate_digest("wasm", &manifest.wasm.sha256)?; + Version::parse(&manifest.version).map_err(|_| Error::AppVersionMismatch { + actual: manifest.version.clone(), + expected: "semver".to_string(), + })?; + Version::parse(&manifest.min_beam_version).map_err(|_| Error::UnsupportedBeamVersion { + app: manifest.id.clone(), + required: manifest.min_beam_version.clone(), + })?; + + for command in &manifest.commands { + validate_command_name(&command.name)?; + if let Some(docs) = &command.docs { + for argument in &docs.arguments { + validate_doc_name(&argument.name)?; + } + for option in &docs.options { + validate_doc_name(&option.name)?; + } + } + } + if let Some(icon) = &manifest.icon { + validate_digest("icon", &icon.sha256)?; + parse_registry_url(&icon.url)?; + } + for http in &manifest.permissions.http { + validate_url(&http.url)?; + } + for chain in &manifest.permissions.chains { + validate_chain_permission(chain)?; + } + + Ok(()) +} + +pub fn ensure_beam_version(app: &str, required: &str) -> Result<()> { + let current = + Version::parse(env!("CARGO_PKG_VERSION")).map_err(|_| Error::UnsupportedBeamVersion { + app: app.to_string(), + required: required.to_string(), + })?; + let required = Version::parse(required).map_err(|_| Error::UnsupportedBeamVersion { + app: app.to_string(), + required: required.to_string(), + })?; + if current < required { + return Err(Error::UnsupportedBeamVersion { + app: app.to_string(), + required: required.to_string(), + }); + } + + Ok(()) +} + +fn validate_chain_permission(permission: &ChainPermission) -> Result<()> { + validate_glob(&permission.chain)?; + validate_optional_globs(permission.contracts.as_deref())?; + validate_optional_globs(permission.spenders.as_deref())?; + + if let Some(selectors) = permission.selectors.as_deref() { + for selector in selectors { + if selector.contains('*') { + validate_glob(selector)?; + } else { + validate_selector(selector)?; + } + } + } + + Ok(()) +} + +fn validate_app_id(value: &str) -> Result<()> { + if value.is_empty() + || !value + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + { + return Err(Error::InvalidAppId { + value: value.to_string(), + }); + } + + Ok(()) +} + +fn validate_command_name(value: &str) -> Result<()> { + if value.is_empty() + || !value + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + { + return Err(Error::InvalidCommandName { + value: value.to_string(), + }); + } + + Ok(()) +} + +fn validate_doc_name(value: &str) -> Result<()> { + if value.is_empty() { + return Err(Error::InvalidCommandName { + value: value.to_string(), + }); + } + + Ok(()) +} + +fn validate_digest(artifact: &str, digest: &str) -> Result<()> { + let Some(hex) = digest.strip_prefix("sha256:") else { + return Err(Error::InvalidDigest { + artifact: artifact.to_string(), + digest: digest.to_string(), + }); + }; + if hex.len() != 64 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(Error::InvalidDigest { + artifact: artifact.to_string(), + digest: digest.to_string(), + }); + } + + Ok(()) +} + +fn validate_url(value: &str) -> Result<()> { + if value.contains('*') { + validate_glob(value)?; + return Ok(()); + } + + let url = Url::parse(value).map_err(|_| Error::InvalidPermissionUrl { + value: value.to_string(), + })?; + if url.scheme() != "https" { + return Err(Error::InvalidPermissionUrl { + value: value.to_string(), + }); + } + if is_blocked_host(url.host_str()) { + return Err(Error::InvalidPermissionUrl { + value: value.to_string(), + }); + } + + Ok(()) +} + +fn validate_registry_artifact_url(registry_url: &str, value: &str) -> Result<()> { + let registry = parse_registry_url(registry_url)?; + let url = parse_registry_url(value)?; + let prefix = format!("{}/", registry.as_str().trim_end_matches('/')); + + if !same_origin(®istry, &url) || !value.starts_with(&prefix) { + return Err(Error::InvalidRegistryUrl { + value: value.to_string(), + }); + } + + Ok(()) +} + +fn parse_registry_url(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| Error::InvalidRegistryUrl { + value: value.to_string(), + })?; + if url.query().is_some() || url.fragment().is_some() { + return Err(Error::InvalidRegistryUrl { + value: value.to_string(), + }); + } + + match url.scheme() { + "https" if !is_blocked_host(url.host_str()) => Ok(url), + "http" if is_loopback_host(url.host_str()) => Ok(url), + _ => Err(Error::InvalidRegistryUrl { + value: value.to_string(), + }), + } +} + +fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() + && left.host_str() == right.host_str() + && left.port_or_known_default() == right.port_or_known_default() +} + +fn is_blocked_host(host: Option<&str>) -> bool { + let Some(host) = host else { + return true; + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + match host.parse::() { + Ok(IpAddr::V4(ip)) => { + ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() + } + Ok(IpAddr::V6(ip)) => ip.is_loopback() || ip.is_unspecified() || ip.is_unique_local(), + Err(_) => false, + } +} + +fn is_loopback_host(host: Option<&str>) -> bool { + let Some(host) = host else { + return false; + }; + if host.eq_ignore_ascii_case("localhost") { + return true; + } + match host.parse::() { + Ok(IpAddr::V4(ip)) => ip.is_loopback(), + Ok(IpAddr::V6(ip)) => ip.is_loopback(), + Err(_) => false, + } +} + +fn validate_optional_globs(values: Option<&[String]>) -> Result<()> { + if let Some(values) = values { + for value in values { + validate_glob(value)?; + } + } + + Ok(()) +} + +fn validate_glob(value: &str) -> Result<()> { + if value.is_empty() || value.contains("**") { + return Err(Error::InvalidPermissionGlob { + value: value.to_string(), + }); + } + + Ok(()) +} + +fn validate_selector(value: &str) -> Result<()> { + let normalized = value.strip_prefix("0x").unwrap_or(value); + if normalized.len() != 8 || !normalized.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(Error::InvalidPermissionSelector { + value: value.to_string(), + }); + } + + Ok(()) +} diff --git a/pkg/beam-cli/src/cli.rs b/pkg/beam-cli/src/cli.rs index 4412c82..f9da6f5 100644 --- a/pkg/beam-cli/src/cli.rs +++ b/pkg/beam-cli/src/cli.rs @@ -1,8 +1,12 @@ -// lint-long-file-override allow-max-lines=300 +// lint-long-file-override allow-max-lines=330 +mod apps; mod chain; +mod contract; mod fetch; +mod gas; mod normalize; mod privacy; +mod wallet; pub mod util; @@ -10,11 +14,15 @@ use clap::{Args, Parser, Subcommand}; use crate::{display::ColorMode, output::OutputMode, runtime::InvocationOverrides}; +pub use apps::*; pub use chain::*; +pub use contract::*; pub use fetch::FetchArgs; +pub use gas::*; pub(crate) use normalize::normalize_cli_args; pub use privacy::*; use util::UtilAction; +pub use wallet::*; #[derive(Debug, Parser)] #[command(name = "beam", version, about = "Ethereum wallet CLI")] @@ -76,6 +84,13 @@ pub enum Command { #[command(subcommand)] action: Option, }, + /// Manage Beam apps + Apps { + #[command(subcommand)] + action: AppsAction, + }, + /// Run a Beam app + X(AppRunArgs), /// Work with private balances and transfers Privacy { #[command(subcommand)] @@ -85,6 +100,12 @@ pub enum Command { Balance(BalanceArgs), /// Send the native token Transfer(TransferArgs), + /// Estimate gas for a native transfer or contract transaction + #[command(name = "gas", visible_aliases = ["estimate-gas", "estimate"])] + Gas { + #[command(subcommand)] + action: GasAction, + }, /// Inspect a transaction #[command(name = "txn", visible_alias = "tx")] Txn(TxnArgs), @@ -95,6 +116,11 @@ pub enum Command { #[command(subcommand)] action: Erc20Action, }, + /// Inspect deployed contracts + Contract { + #[command(subcommand)] + action: ContractAction, + }, /// Run a read-only contract call Call(CallArgs), /// Send a contract transaction @@ -107,49 +133,6 @@ pub enum Command { RefreshUpdateStatus, } -#[derive(Debug, Subcommand)] -pub enum WalletAction { - /// Create a new wallet - Create { name: Option }, - /// Import a wallet from a private key - Import { - #[command(flatten)] - private_key_source: PrivateKeySourceArgs, - #[arg(long)] - name: Option, - }, - /// List stored wallets - List, - /// Rename a stored wallet - Rename { name: String, new_name: String }, - /// Derive an address from a private key - Address { - #[command(flatten)] - private_key_source: PrivateKeySourceArgs, - }, - /// Set the default wallet - Use { name: String }, -} - -#[derive(Clone, Debug, Default, Args, PartialEq, Eq)] -pub struct PrivateKeySourceArgs { - #[arg( - long, - default_value_t = false, - conflicts_with = "private_key_fd", - help = "Read the private key from stdin instead of prompting" - )] - pub private_key_stdin: bool, - - #[arg( - long, - value_name = "FD", - conflicts_with = "private_key_stdin", - help = "Read the private key from an already-open file descriptor" - )] - pub private_key_fd: Option, -} - #[derive(Debug, Subcommand)] pub enum RpcAction { /// List RPC endpoints for the active chain @@ -268,12 +251,6 @@ impl Command { } } -impl WalletAction { - pub(crate) fn is_sensitive(&self) -> bool { - matches!(self, Self::Import { .. } | Self::Address { .. }) - } -} - impl PrivacyAction { pub(crate) fn is_sensitive(&self) -> bool { match self { diff --git a/pkg/beam-cli/src/cli/apps.rs b/pkg/beam-cli/src/cli/apps.rs new file mode 100644 index 0000000..6092faf --- /dev/null +++ b/pkg/beam-cli/src/cli/apps.rs @@ -0,0 +1,67 @@ +use clap::{Args, Subcommand}; + +#[derive(Debug, Subcommand)] +pub enum AppsAction { + /// Install an app from the Beam registry + Install(AppInstallArgs), + /// List installed apps + List, + /// Show app metadata + Info { app: String }, + /// Update one app or every installed app + Update { app: Option }, + /// Remove an installed app + Remove(AppRemoveArgs), + /// Show app permissions + Permissions { app: String }, + /// Run an installed app + Run(AppRunArgs), + /// Manage app approval continuations + Approvals { + #[command(subcommand)] + action: AppApprovalAction, + }, +} + +#[derive(Clone, Debug, Args)] +pub struct AppInstallArgs { + pub app: String, + #[arg(long)] + pub version: Option, + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +#[derive(Clone, Debug, Args)] +pub struct AppRemoveArgs { + pub app: String, + #[arg(long, default_value_t = false)] + pub purge_data: bool, +} + +#[derive(Clone, Debug, Args)] +pub struct AppRunArgs { + pub app: String, + #[arg(long, default_value_t = false)] + pub prepare: bool, + #[arg(long, default_value_t = false)] + pub no_prompt: bool, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub args: Vec, +} + +#[derive(Debug, Subcommand)] +pub enum AppApprovalAction { + /// List app approval continuations + List, + /// Show an app approval continuation + Show { approval_id: String }, + /// Approve an app approval continuation + Approve { + approval_id: String, + #[arg(long, default_value_t = false)] + execute: bool, + }, + /// Reject an app approval continuation + Reject { approval_id: String }, +} diff --git a/pkg/beam-cli/src/cli/contract.rs b/pkg/beam-cli/src/cli/contract.rs new file mode 100644 index 0000000..c08834e --- /dev/null +++ b/pkg/beam-cli/src/cli/contract.rs @@ -0,0 +1,57 @@ +use clap::{Args, Subcommand}; + +#[derive(Debug, Subcommand)] +pub enum ContractAction { + /// Show deployed contract summary information for a literal 0x address + Info(ContractAddressArgs), + #[command( + long_about = "Print runtime bytecode from the active RPC. Stdout is pipeable. There is no `code` alias; use `bytecode`." + )] + /// Print pipeable runtime bytecode from the active RPC + Bytecode(ContractBytecodeArgs), + #[command( + long_about = "Print the verified ABI from Sourcify for the exact literal 0x address. Beam does not follow proxies automatically; proxy implementation addresses are shown only as tips." + )] + /// Print the pipeable verified ABI from Sourcify + Abi(ContractAddressArgs), + #[command( + long_about = "Print verified source information or one pipeable source file from Sourcify for the exact literal 0x address. Beam does not follow proxies automatically. Use `--` before a source path that begins with `-`." + )] + /// Print verified source information or one pipeable source file + Source(ContractSourceArgs), + #[command( + long_about = "Export the verified Sourcify source bundle for the exact literal 0x address. Beam does not follow proxies automatically. Use `--` before a destination that begins with `-`." + )] + /// Export the verified Sourcify source bundle + Export(ContractExportArgs), +} + +#[derive(Clone, Debug, Args)] +pub struct ContractAddressArgs { + #[arg(help = "Literal 0x contract address; ENS names and aliases are not accepted")] + pub address: String, +} + +#[derive(Clone, Debug, Args)] +pub struct ContractBytecodeArgs { + #[arg(help = "Literal 0x contract address; ENS names and aliases are not accepted")] + pub address: String, + #[arg(long)] + pub block: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct ContractSourceArgs { + #[arg(help = "Literal 0x contract address; ENS names and aliases are not accepted")] + pub address: String, + #[arg(help = "Sourcify source path; use `--` before values that begin with `-`")] + pub source_path: Option, +} + +#[derive(Clone, Debug, Args)] +pub struct ContractExportArgs { + #[arg(help = "Literal 0x contract address; ENS names and aliases are not accepted")] + pub address: String, + #[arg(help = "Export destination directory; use `--` before values that begin with `-`")] + pub destination: String, +} diff --git a/pkg/beam-cli/src/cli/gas.rs b/pkg/beam-cli/src/cli/gas.rs new file mode 100644 index 0000000..9b49d6e --- /dev/null +++ b/pkg/beam-cli/src/cli/gas.rs @@ -0,0 +1,32 @@ +use clap::Subcommand; + +use super::{SendArgs, TransferArgs}; + +#[derive(Debug, Subcommand)] +pub enum GasAction { + /// Estimate gas for a native token transfer + Transfer(TransferArgs), + /// Estimate gas for ERC20 token operations + Erc20 { + #[command(subcommand)] + action: Erc20GasAction, + }, + /// Estimate gas for a contract transaction + Send(SendArgs), +} + +#[derive(Debug, Subcommand)] +pub enum Erc20GasAction { + /// Estimate gas for an ERC20 token transfer + Transfer { + token: String, + to: String, + amount: String, + }, + /// Estimate gas for an ERC20 approval + Approve { + token: String, + spender: String, + amount: String, + }, +} diff --git a/pkg/beam-cli/src/cli/wallet.rs b/pkg/beam-cli/src/cli/wallet.rs new file mode 100644 index 0000000..d532721 --- /dev/null +++ b/pkg/beam-cli/src/cli/wallet.rs @@ -0,0 +1,50 @@ +use clap::{Args, Subcommand}; + +#[derive(Debug, Subcommand)] +pub enum WalletAction { + /// Create a new wallet + Create { name: Option }, + /// Import a wallet from a private key + Import { + #[command(flatten)] + private_key_source: PrivateKeySourceArgs, + #[arg(long)] + name: Option, + }, + /// List stored wallets + List, + /// Rename a stored wallet + Rename { name: String, new_name: String }, + /// Derive an address from a private key + Address { + #[command(flatten)] + private_key_source: PrivateKeySourceArgs, + }, + /// Set the default wallet + Use { name: String }, +} + +#[derive(Clone, Debug, Default, Args, PartialEq, Eq)] +pub struct PrivateKeySourceArgs { + #[arg( + long, + default_value_t = false, + conflicts_with = "private_key_fd", + help = "Read the private key from stdin instead of prompting" + )] + pub private_key_stdin: bool, + + #[arg( + long, + value_name = "FD", + conflicts_with = "private_key_stdin", + help = "Read the private key from an already-open file descriptor" + )] + pub private_key_fd: Option, +} + +impl WalletAction { + pub(crate) fn is_sensitive(&self) -> bool { + matches!(self, Self::Import { .. } | Self::Address { .. }) + } +} diff --git a/pkg/beam-cli/src/commands/apps/execution.rs b/pkg/beam-cli/src/commands/apps/execution.rs new file mode 100644 index 0000000..30adb2d --- /dev/null +++ b/pkg/beam-cli/src/commands/apps/execution.rs @@ -0,0 +1,240 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use serde_json::{Value, json}; + +use crate::{ + apps::{ + Error as AppError, + model::{ActionPlan, ActionStep}, + }, + commands::signing::prompt_active_signer, + error::{Error, Result}, + evm::{CalldataTransaction, TransactionGas, erc20_allowance, send_calldata_with_gas}, + output::{ + CommandOutput, confirmed_transaction_message, dropped_transaction_message, + pending_transaction_message, with_loading_handle, + }, + runtime::{BeamApp, parse_address}, + signer::Signer, + transaction::{TransactionExecution, loading_message}, +}; + +pub async fn execute_plan(app: &BeamApp, plan: &ActionPlan) -> Result { + let executable = plan.steps.iter().any(|step| transaction(step).is_some()); + if !executable { + return Ok(render_simulated_execution(plan)); + } + + let (chain, client) = app.active_chain_client().await?; + let signer = prompt_active_signer(app).await?; + let mut outputs = Vec::new(); + for step in &plan.steps { + let Some(transaction) = transaction(step) else { + continue; + }; + if should_skip_approval(&client, signer.address(), step).await? { + outputs.push(json!({ + "block_number": null, + "state": "skipped", + "status": null, + "summary": format!("Skipped {}; allowance already sufficient", step.summary), + "tx_hash": null, + })); + continue; + } + let to = parse_address(transaction.to()?)?; + let data = parse_hex_data(transaction.data()?)?; + let value = parse_u256(transaction.value().unwrap_or("0"))?; + let gas = parse_gas(&transaction)?; + let action = step.summary.clone(); + let execution = with_loading_handle( + app.output_mode, + format!("Sending {action} and waiting for confirmation..."), + |loading| async { + send_calldata_with_gas( + &client, + &signer, + CalldataTransaction { + data, + gas, + to, + value, + }, + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + }, + ) + .await?; + outputs.push(step_output(step, execution)); + } + + Ok(CommandOutput::new( + render_execution_summary(plan, &outputs), + json!({ + "app": plan.app_id, + "chain": chain.entry.key, + "command": plan.command, + "state": aggregate_state(&outputs), + "steps": outputs, + }), + )) +} + +fn render_simulated_execution(plan: &ActionPlan) -> CommandOutput { + CommandOutput::new( + format!("Executed app action: {}", plan.command), + json!({ + "app": plan.app_id, + "chain": plan.chain, + "command": plan.command, + "state": "executed", + "steps": plan.steps, + }), + ) +} + +fn render_execution_summary(plan: &ActionPlan, outputs: &[Value]) -> String { + let mut lines = vec![format!("Executed app action: {}", plan.command)]; + for output in outputs { + let summary = output["summary"].as_str().unwrap_or("transaction"); + let tx_hash = output["tx_hash"].as_str().unwrap_or("unknown"); + let state = output["state"].as_str().unwrap_or("unknown"); + lines.push(format!(" - {summary}: {state} ({tx_hash})")); + } + lines.join("\n") +} + +fn step_output(step: &ActionStep, execution: TransactionExecution) -> Value { + match execution { + TransactionExecution::Confirmed(outcome) => json!({ + "block_number": outcome.block_number, + "state": "confirmed", + "status": outcome.status, + "summary": confirmed_transaction_message(&step.summary, &outcome.tx_hash, outcome.block_number), + "tx_hash": outcome.tx_hash, + }), + TransactionExecution::Pending(pending) => json!({ + "block_number": pending.block_number, + "state": "pending", + "status": null, + "summary": pending_transaction_message(&step.summary, &pending.tx_hash, pending.block_number), + "tx_hash": pending.tx_hash, + }), + TransactionExecution::Dropped(dropped) => json!({ + "block_number": dropped.block_number, + "state": "dropped", + "status": null, + "summary": dropped_transaction_message(&step.summary, &dropped.tx_hash, dropped.block_number), + "tx_hash": dropped.tx_hash, + }), + } +} + +fn aggregate_state(outputs: &[Value]) -> &'static str { + if outputs.iter().any(|output| output["state"] == "dropped") { + "dropped" + } else if outputs.iter().any(|output| output["state"] == "pending") { + "pending" + } else if outputs.iter().all(|output| output["state"] == "skipped") { + "skipped" + } else { + "confirmed" + } +} + +async fn should_skip_approval( + client: &contracts::Client, + wallet: contracts::Address, + step: &ActionStep, +) -> Result { + if step.kind != "erc20-approval" { + return Ok(false); + } + let (Some(target), Some(spender), Some(value)) = ( + step.target.as_deref(), + step.spender.as_deref(), + step.value.as_deref(), + ) else { + return Ok(false); + }; + let required = parse_u256(value)?; + let allowance = erc20_allowance( + client, + parse_address(target)?, + wallet, + parse_address(spender)?, + ) + .await?; + + Ok(allowance >= required) +} + +fn transaction(step: &ActionStep) -> Option> { + step.metadata + .get("transaction") + .and_then(Value::as_object) + .map(TransactionValue) +} + +struct TransactionValue<'a>(&'a serde_json::Map); + +impl TransactionValue<'_> { + fn data(&self) -> Result<&str> { + self.string("data") + } + + fn gas_limit(&self) -> Option<&str> { + self.optional_string("gas_limit") + } + + fn gas_price(&self) -> Option<&str> { + self.optional_string("gas_price") + } + + fn to(&self) -> Result<&str> { + self.string("to") + } + + fn value(&self) -> Option<&str> { + self.optional_string("value") + } + + fn string(&self, key: &str) -> Result<&str> { + self.optional_string(key).ok_or_else(|| { + Error::App(AppError::InvalidHostRequest { + reason: format!("transaction missing {key}"), + }) + }) + } + + fn optional_string(&self, key: &str) -> Option<&str> { + self.0.get(key).and_then(Value::as_str) + } +} + +fn parse_gas(transaction: &TransactionValue<'_>) -> Result> { + match (transaction.gas_limit(), transaction.gas_price()) { + (Some(gas_limit), Some(gas_price)) => Ok(Some(TransactionGas { + gas_limit: parse_u256(gas_limit)?, + gas_price: parse_u256(gas_price)?, + })), + _ => Ok(None), + } +} + +fn parse_hex_data(value: &str) -> Result> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| Error::InvalidHexData { + value: value.to_string(), + }) +} + +fn parse_u256(value: &str) -> Result { + if let Some(value) = value.strip_prefix("0x") { + return Ok(contracts::U256::from_str_radix(value, 16).context("parse hex u256")?); + } + Ok(value + .parse::() + .context("parse decimal u256")?) +} diff --git a/pkg/beam-cli/src/commands/apps/mod.rs b/pkg/beam-cli/src/commands/apps/mod.rs new file mode 100644 index 0000000..873412f --- /dev/null +++ b/pkg/beam-cli/src/commands/apps/mod.rs @@ -0,0 +1,356 @@ +// lint-long-file-override allow-max-lines=400 +mod execution; +mod plans; +mod prompt; +mod render; + +use std::fs; + +use contextful::ResultContextExt; +use serde_json::json; + +use crate::{ + apps::{ + Error as AppError, + approvals::{ApprovalStore, ensure_approval_executable}, + model::{ActionPlan, AppManifest, ApprovalRecord}, + registry::{ + ensure_manifest_matches, fetch_index, fetch_manifest, fetch_module, + registry_url_from_env, select_app, select_version, + }, + runtime::validate_wasm_module, + store::AppCache, + }, + cli::{AppApprovalAction, AppInstallArgs, AppRemoveArgs, AppRunArgs, AppsAction}, + error::Result, + output::CommandOutput, + runtime::BeamApp, + table::render_table, +}; + +use execution::execute_plan; +use plans::{plan_for_command, validate_plan_permissions}; +use prompt::approve_interactively; +use render::{ + approval_json, manifest_json, permissions_json, render_app_help, render_approval, + render_approval_created, render_execution, render_install_summary, render_manifest_info, + render_permission_diff, render_permissions, +}; + +pub async fn run(app: &BeamApp, action: AppsAction) -> Result<()> { + match action { + AppsAction::Install(args) => install(app, args).await, + AppsAction::List => list(app).await, + AppsAction::Info { app: app_id } => info(app, &app_id).await, + AppsAction::Update { app: app_id } => update(app, app_id.as_deref()).await, + AppsAction::Remove(args) => remove(app, args).await, + AppsAction::Permissions { app: app_id } => permissions(app, &app_id).await, + AppsAction::Run(args) => run_app(app, args).await, + AppsAction::Approvals { action } => approvals(app, action).await, + } +} + +pub async fn run_app(app: &BeamApp, args: AppRunArgs) -> Result<()> { + let prepare = args.prepare || args.args.iter().any(|arg| arg == "--prepare"); + let no_prompt = args.no_prompt || args.args.iter().any(|arg| arg == "--no-prompt"); + let command_args = filtered_app_args(&args.args); + let cache = AppCache::load(&app.paths.root).await?; + let (installed, manifest) = cache.active_manifest(&args.app).await?; + + fs::create_dir_all(cache.data_dir(&args.app)).context("create beam app data directory")?; + validate_wasm_module( + &args.app, + &manifest.wasm.entrypoint, + &cache.module_path(&args.app, &installed.active_version), + )?; + + let command = command_args + .first() + .cloned() + .unwrap_or_else(|| "help".to_string()); + if command == "help" || args.args.iter().any(|arg| arg == "--help" || arg == "-h") { + return CommandOutput::new(render_app_help(&manifest), manifest_json(&manifest)) + .print(app.output_mode); + } + + let plan = plan_for_command(app, &manifest, &installed, &command_args).await?; + validate_plan_permissions(&manifest.permissions, &plan)?; + let approval_required = plan_requires_approval(&plan); + + if prepare { + let approvals = ApprovalStore::load(&app.paths.root).await?; + let approval = approvals.create(plan).await?; + return render_approval_created(&approval).print(app.output_mode); + } + + if approval_required { + if no_prompt { + return Err(AppError::ApprovalRequired.into()); + } + approve_interactively(&render::render_plan(&plan))?; + } + render_execution(&plan).print(app.output_mode) +} + +fn plan_requires_approval(plan: &ActionPlan) -> bool { + plan.steps + .iter() + .any(|step| step.kind == "erc20-approval" || step.kind == "transaction") +} + +fn filtered_app_args(args: &[String]) -> Vec { + args.iter() + .filter(|arg| arg.as_str() != "--prepare" && arg.as_str() != "--no-prompt") + .cloned() + .collect() +} + +async fn install(app: &BeamApp, args: AppInstallArgs) -> Result<()> { + let registry_url = registry_url_from_env(); + let index = fetch_index(®istry_url).await?; + let registry_app = select_app(&index, &args.app)?; + let version = select_version(registry_app, args.version.as_deref())?; + let (manifest, manifest_bytes) = fetch_manifest(version).await?; + ensure_manifest_matches(&args.app, version, &manifest)?; + let module_bytes = fetch_module(version, &manifest).await?; + let summary = render_install_summary(&manifest, version.module_sha256.as_str(), ®istry_url); + + if args.dry_run { + return CommandOutput::new( + summary, + json!({ + "app": manifest.id, + "dry_run": true, + "permissions": permissions_json(&manifest.permissions), + "version": manifest.version, + }), + ) + .print(app.output_mode); + } + + approve_interactively(&summary)?; + let cache = AppCache::load(&app.paths.root).await?; + cache + .install( + &manifest, + &manifest_bytes, + &module_bytes, + &version.manifest_sha256, + &version.module_sha256, + ®istry_url, + ) + .await?; + + CommandOutput::new( + format!("Installed {} {}", manifest.display_name, manifest.version), + json!({ + "app": manifest.id, + "installed": true, + "permissions": permissions_json(&manifest.permissions), + "version": manifest.version, + }), + ) + .print(app.output_mode) +} + +async fn list(app: &BeamApp) -> Result<()> { + let cache = AppCache::load(&app.paths.root).await?; + let installed = cache.installed().await.installed; + if installed.is_empty() { + return CommandOutput::message("No Beam apps installed.").print(app.output_mode); + } + let rows = installed + .iter() + .map(|app| vec![app.id.clone(), app.active_version.clone()]) + .collect::>(); + + CommandOutput::new( + render_table(&["App", "Version"], &rows), + json!({ "apps": installed }), + ) + .print(app.output_mode) +} + +async fn info(app: &BeamApp, app_id: &str) -> Result<()> { + let cache = AppCache::load(&app.paths.root).await?; + let (_, manifest) = cache.active_manifest(app_id).await?; + CommandOutput::new(render_manifest_info(&manifest), manifest_json(&manifest)) + .print(app.output_mode) +} + +async fn update(app: &BeamApp, app_id: Option<&str>) -> Result<()> { + let targets = match app_id { + Some(app_id) => vec![app_id.to_string()], + None => { + let cache = AppCache::load(&app.paths.root).await?; + cache + .installed() + .await + .installed + .into_iter() + .map(|app| app.id) + .collect::>() + } + }; + if targets.is_empty() { + return CommandOutput::message("No apps installed.").print(app.output_mode); + } + + let mut updated = Vec::new(); + for target in targets { + let manifest = update_one(app, &target).await?; + updated.push(vec![manifest.id, manifest.version]); + } + + CommandOutput::new( + render_table(&["App", "Version"], &updated), + json!({ "updated": updated }), + ) + .print(app.output_mode) +} + +async fn update_one(app: &BeamApp, app_id: &str) -> Result { + let registry_url = registry_url_from_env(); + let index = fetch_index(®istry_url).await?; + let registry_app = select_app(&index, app_id)?; + let version = select_version(registry_app, None)?; + let (manifest, manifest_bytes) = fetch_manifest(version).await?; + ensure_manifest_matches(app_id, version, &manifest)?; + let module_bytes = fetch_module(version, &manifest).await?; + let cache = AppCache::load(&app.paths.root).await?; + let (_, current) = cache.active_manifest(app_id).await?; + if current.permissions != manifest.permissions { + approve_interactively(&render_permission_diff(¤t, &manifest))?; + } + cache + .install( + &manifest, + &manifest_bytes, + &module_bytes, + &version.manifest_sha256, + &version.module_sha256, + ®istry_url, + ) + .await?; + + Ok(manifest) +} + +async fn remove(app: &BeamApp, args: AppRemoveArgs) -> Result<()> { + AppCache::load(&app.paths.root) + .await? + .remove(&args.app, args.purge_data) + .await?; + CommandOutput::new( + format!("Removed {}", args.app), + json!({ "app": args.app, "purged_data": args.purge_data, "removed": true }), + ) + .print(app.output_mode) +} + +async fn permissions(app: &BeamApp, app_id: &str) -> Result<()> { + let cache = AppCache::load(&app.paths.root).await?; + let (_, manifest) = cache.active_manifest(app_id).await?; + CommandOutput::new( + render_permissions(&manifest.permissions), + permissions_json(&manifest.permissions), + ) + .print(app.output_mode) +} + +async fn approvals(app: &BeamApp, action: AppApprovalAction) -> Result<()> { + let store = ApprovalStore::load(&app.paths.root).await?; + match action { + AppApprovalAction::List => list_approvals(app, store.list().await), + AppApprovalAction::Show { approval_id } => { + let approval = store.find(&approval_id).await?; + CommandOutput::new(render_approval(&approval), approval_json(&approval)) + .print(app.output_mode) + } + AppApprovalAction::Approve { + approval_id, + execute, + } => { + let approval = store.find(&approval_id).await?; + if execute { + ensure_approval_executable(&approval)?; + ensure_approval_matches_active(app, &approval).await?; + let output = execute_plan(app, &approval.plan).await?; + store.mark_executed(&approval_id).await?; + return output.print(app.output_mode); + } + let approval = store.approve(&approval_id).await?; + CommandOutput::new( + format!("Approved {}", approval.id), + approval_json(&approval), + ) + .print(app.output_mode) + } + AppApprovalAction::Reject { approval_id } => { + let approval = store.reject(&approval_id).await?; + CommandOutput::new( + format!("Rejected {}", approval.id), + approval_json(&approval), + ) + .print(app.output_mode) + } + } +} + +fn list_approvals(app: &BeamApp, approvals: Vec) -> Result<()> { + let rows = approvals + .iter() + .map(|approval| { + vec![ + approval.id.clone(), + format!("{:?}", approval.status), + approval.plan.command.clone(), + ] + }) + .collect::>(); + CommandOutput::new( + render_table(&["Approval", "Status", "Command"], &rows), + json!({ "approvals": approvals }), + ) + .print(app.output_mode) +} + +async fn ensure_approval_matches_active(app: &BeamApp, approval: &ApprovalRecord) -> Result<()> { + let cache = AppCache::load(&app.paths.root).await?; + let (installed, _) = cache.active_manifest(&approval.plan.app_id).await?; + let artifact_matches = installed.active_version == approval.plan.app_version + && installed.manifest_sha256 == approval.plan.manifest_sha256 + && installed.module_sha256 == approval.plan.wasm_sha256; + if !artifact_matches { + return Err(AppError::ApprovalArtifactChanged { + approval_id: approval.id.clone(), + } + .into()); + } + + let active_chain = app.active_chain().await?; + if active_chain.entry.key != approval.plan.chain { + return Err(AppError::ApprovalContextChanged { + actual: active_chain.entry.key, + approval_id: approval.id.clone(), + expected: approval.plan.chain.clone(), + field: "chain".to_string(), + } + .into()); + } + + if let Some(expected_wallet) = approval.plan.wallet.as_ref() { + let actual_wallet = format!("{:#x}", app.active_address().await?); + if !actual_wallet.eq_ignore_ascii_case(expected_wallet) { + return Err(AppError::ApprovalContextChanged { + actual: actual_wallet, + approval_id: approval.id.clone(), + expected: expected_wallet.clone(), + field: "wallet".to_string(), + } + .into()); + } + } + + Ok(()) +} diff --git a/pkg/beam-cli/src/commands/apps/plans.rs b/pkg/beam-cli/src/commands/apps/plans.rs new file mode 100644 index 0000000..7bcc459 --- /dev/null +++ b/pkg/beam-cli/src/commands/apps/plans.rs @@ -0,0 +1,77 @@ +use crate::{ + apps::{ + Error as AppError, + model::{ + ActionPlan, ActionStep, AppManifest, AppPermissions, ChainOperation, InstalledApp, + }, + permissions::ensure_chain_scope, + }, + error::Result, + runtime::BeamApp, +}; + +pub(super) async fn plan_for_command( + _app: &BeamApp, + manifest: &AppManifest, + _installed: &InstalledApp, + args: &[String], +) -> Result { + match (manifest.id.as_str(), args.first().map(String::as_str)) { + (_, Some(command)) => Err(AppError::UnsupportedAppCommand { + command: command.to_string(), + } + .into()), + (_, None) => Err(AppError::UnsupportedAppCommand { + command: "".to_string(), + } + .into()), + } +} + +pub(super) fn validate_plan_permissions( + permissions: &AppPermissions, + plan: &ActionPlan, +) -> Result<()> { + for step in &plan.steps { + if let Some(target) = step.target.as_deref() { + ensure_chain_scope( + permissions, + &plan.chain, + operation_for_step(step), + Some(target), + None, + None, + )?; + } + if let Some(selector) = step.selector.as_deref() { + ensure_chain_scope( + permissions, + &plan.chain, + operation_for_step(step), + None, + Some(selector), + None, + )?; + } + if let Some(spender) = step.spender.as_deref() { + ensure_chain_scope( + permissions, + &plan.chain, + operation_for_step(step), + None, + None, + Some(spender), + )?; + } + } + + Ok(()) +} + +fn operation_for_step(step: &ActionStep) -> ChainOperation { + if step.kind == "erc20-approval" { + ChainOperation::Erc20Approval + } else { + ChainOperation::SendTransaction + } +} diff --git a/pkg/beam-cli/src/commands/apps/prompt.rs b/pkg/beam-cli/src/commands/apps/prompt.rs new file mode 100644 index 0000000..77b2302 --- /dev/null +++ b/pkg/beam-cli/src/commands/apps/prompt.rs @@ -0,0 +1,38 @@ +use std::io::{BufRead, Write}; + +use contextful::ResultContextExt; + +use crate::{ + apps::Error as AppError, + error::{Error, Result}, +}; + +pub(super) fn approve_interactively(summary: &str) -> Result<()> { + let stdin = std::io::stdin(); + let stderr = std::io::stderr(); + approve_interactively_with(summary, &mut stdin.lock(), &mut stderr.lock()) +} + +fn approve_interactively_with(summary: &str, input: &mut R, output: &mut W) -> Result<()> +where + R: BufRead, + W: Write, +{ + writeln!(output, "{summary}").context("write beam app approval summary")?; + write!(output, "Approve? [y/N]: ").context("write beam app approval prompt")?; + output.flush().context("flush beam app approval prompt")?; + let mut value = String::new(); + if input + .read_line(&mut value) + .context("read beam app approval prompt")? + == 0 + { + return Err(Error::PromptClosed { + label: "beam app approval".to_string(), + }); + } + match value.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => Ok(()), + _ => Err(AppError::ApprovalRejected.into()), + } +} diff --git a/pkg/beam-cli/src/commands/apps/render.rs b/pkg/beam-cli/src/commands/apps/render.rs new file mode 100644 index 0000000..e75b35c --- /dev/null +++ b/pkg/beam-cli/src/commands/apps/render.rs @@ -0,0 +1,165 @@ +use serde_json::{Value, json}; + +use crate::{ + apps::model::{ActionPlan, AppManifest, AppPermissions, ApprovalRecord}, + output::CommandOutput, +}; + +pub(super) fn render_install_summary( + manifest: &AppManifest, + module_sha256: &str, + registry_url: &str, +) -> String { + format!( + "Install {} {}?\nPublisher: {}\nSource: {}\nWASM digest: {}\n\n{}", + manifest.display_name, + manifest.version, + manifest.publisher, + registry_url, + module_sha256, + render_permissions(&manifest.permissions) + ) +} + +pub(super) fn render_manifest_info(manifest: &AppManifest) -> String { + format!( + "{} {}\nPublisher: {}\n{}\n\n{}", + manifest.display_name, + manifest.version, + manifest.publisher, + manifest.description, + render_permissions(&manifest.permissions) + ) +} + +pub(super) fn render_permissions(permissions: &AppPermissions) -> String { + let mut lines = Vec::new(); + lines.push("Network:".to_string()); + if permissions.http.is_empty() { + lines.push(" - none".to_string()); + } else { + for http in &permissions.http { + lines.push(format!(" - {}", http.url)); + } + } + lines.push("Contracts:".to_string()); + if permissions.chains.is_empty() { + lines.push(" - none".to_string()); + } else { + for chain in &permissions.chains { + lines.push(format!( + " - {}: contracts {}, selectors {}, spenders {}", + chain.chain, + scope_label(chain.contracts.as_deref(), "any contract"), + scope_label(chain.selectors.as_deref(), "any selector"), + scope_label(chain.spenders.as_deref(), "any spender"), + )); + } + } + lines.push("Wallet actions:".to_string()); + lines.push(format!( + " - balances: {}\n - transaction proposals: {}\n - erc20 approvals: {}", + permissions.wallet.read_balances, + permissions.wallet.propose_transactions, + permissions.wallet.erc20_approval + )); + lines.push(format!( + "Storage:\n - app-local: {}", + permissions.storage.app_local + )); + if !permissions.privacy.is_empty() { + lines.push(format!("Privacy:\n - {:?}", permissions.privacy)); + } + lines.join("\n") +} + +fn scope_label(scope: Option<&[String]>, wildcard: &str) -> String { + scope + .map(|values| values.join(", ")) + .unwrap_or_else(|| wildcard.to_string()) +} + +pub(super) fn render_app_help(manifest: &AppManifest) -> String { + let mut lines = vec![format!("{} commands:", manifest.display_name)]; + for command in &manifest.commands { + lines.push(format!(" {} - {}", command.name, command.about)); + } + lines.join("\n") +} + +pub(super) fn render_plan(plan: &ActionPlan) -> String { + let mut lines = vec![ + format!("App: {} {}", plan.app_id, plan.app_version), + format!("Chain: {}", plan.chain), + "Action:".to_string(), + ]; + for step in &plan.steps { + lines.push(format!(" - {}", step.summary)); + } + lines.push(format!("Expires at: {}", plan.expires_at)); + lines.join("\n") +} + +pub(super) fn render_approval(record: &ApprovalRecord) -> String { + format!( + "Approval: {}\nStatus: {:?}\nPlan hash: {}\n{}", + record.id, + record.status, + record.plan_hash, + render_plan(&record.plan) + ) +} + +pub(super) fn render_approval_created(record: &ApprovalRecord) -> CommandOutput { + CommandOutput::new( + format!( + "{}\nApprove with: beam apps approvals approve {} --execute", + render_approval(record), + record.id + ), + approval_json(record), + ) +} + +pub(super) fn render_execution(plan: &ActionPlan) -> CommandOutput { + CommandOutput::new( + format!("Executed app action: {}", plan.command), + json!({ + "app": plan.app_id, + "chain": plan.chain, + "command": plan.command, + "state": "executed", + "steps": plan.steps, + }), + ) +} + +pub(super) fn render_permission_diff(current: &AppManifest, next: &AppManifest) -> String { + format!( + "Update {} {} -> {} changes permissions.\n\nCurrent:\n{}\n\nNext:\n{}", + current.display_name, + current.version, + next.version, + render_permissions(¤t.permissions), + render_permissions(&next.permissions) + ) +} + +pub(super) fn manifest_json(manifest: &AppManifest) -> Value { + json!({ + "id": manifest.id, + "name": manifest.display_name, + "version": manifest.version, + "publisher": manifest.publisher, + "description": manifest.description, + "permissions": permissions_json(&manifest.permissions), + }) +} + +pub(super) fn permissions_json(permissions: &AppPermissions) -> Value { + serde_json::to_value(permissions).unwrap_or_else(|_| json!({})) +} + +pub(super) fn approval_json(approval: &ApprovalRecord) -> Value { + serde_json::to_value(approval).unwrap_or_else(|_| json!({})) +} diff --git a/pkg/beam-cli/src/commands/contract/artifact.rs b/pkg/beam-cli/src/commands/contract/artifact.rs new file mode 100644 index 0000000..7e5c0e1 --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/artifact.rs @@ -0,0 +1,101 @@ +use sourcify_interface::SourcifyClient; + +use crate::{output::OutputMode, runtime::BeamApp}; + +use super::{ + error::{Error, Result, RuntimeUnchecked}, + info::print_sourcify_miss_tip, + sourcify::{Artifact, artifact_label, lookup_contract}, + target::{InspectionTarget, RpcProbe, optional_rpc_probe}, +}; + +pub(super) async fn runtime_verified_artifact( + app: &BeamApp, + sourcify_client: &dyn SourcifyClient, + target: &InspectionTarget, + fields: Vec, + cap_bytes: usize, + artifact: Artifact, +) -> Result { + match lookup_contract(sourcify_client, target, fields, cap_bytes).await { + Ok(response) => match response.contract.runtime_match { + Some(_) => Ok(response), + None => { + let err = Error::SourcifyRuntimeNotVerified { + address: target.checksum_address.clone(), + artifact: artifact_label(artifact).to_owned(), + runtime_unchecked: None, + }; + print_sourcify_miss_tip(app.output_mode, target, &err, false, None); + Err(err) + } + }, + Err(Error::SourcifyNotVerified { .. }) => { + let err = Error::SourcifyNotVerified { + address: target.checksum_address.clone(), + artifact: artifact_label(artifact).to_owned(), + runtime_unchecked: None, + }; + probe_after_sourcify_miss(app.output_mode, app, target, err).await + } + Err(err) => Err(err), + } +} + +async fn probe_after_sourcify_miss( + output_mode: OutputMode, + app: &BeamApp, + target: &InspectionTarget, + miss_error: Error, +) -> Result { + match optional_rpc_probe(app, target).await? { + RpcProbe::NoRuntimeCode => Err(Error::NoRuntimeCode { + address: target.checksum_address.clone(), + }), + RpcProbe::RuntimeCode => { + print_sourcify_miss_tip(output_mode, target, &miss_error, true, None); + Err(miss_error) + } + RpcProbe::Unchecked { reason } => { + let miss_error = with_runtime_unchecked(miss_error, reason); + let reason = runtime_unchecked_reason(&miss_error); + print_sourcify_miss_tip(output_mode, target, &miss_error, false, reason); + Err(miss_error) + } + } +} + +fn with_runtime_unchecked(err: Error, reason: Option) -> Error { + let runtime_unchecked = Some(RuntimeUnchecked { reason }); + match err { + Error::SourcifyNotVerified { + address, artifact, .. + } => Error::SourcifyNotVerified { + address, + artifact, + runtime_unchecked, + }, + Error::SourcifyRuntimeNotVerified { + address, artifact, .. + } => Error::SourcifyRuntimeNotVerified { + address, + artifact, + runtime_unchecked, + }, + err => err, + } +} + +fn runtime_unchecked_reason(err: &Error) -> Option<&str> { + match err { + Error::SourcifyNotVerified { + runtime_unchecked, .. + } + | Error::SourcifyRuntimeNotVerified { + runtime_unchecked, .. + } => runtime_unchecked + .as_ref() + .and_then(|unchecked| unchecked.reason.as_deref()), + _ => None, + } +} diff --git a/pkg/beam-cli/src/commands/contract/error.rs b/pkg/beam-cli/src/commands/contract/error.rs new file mode 100644 index 0000000..2a236cd --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/error.rs @@ -0,0 +1,140 @@ +use crate::error::Error as BeamError; + +pub(super) type Result = std::result::Result; + +#[derive(Clone, Debug)] +pub(super) struct RuntimeUnchecked { + pub(super) reason: Option, +} + +#[derive(Debug, thiserror::Error)] +pub(super) enum Error { + #[error("invalid_contract_address")] + InvalidContractAddress { value: String }, + + #[error("rpc_chain_mismatch")] + RpcChainMismatch { + actual: u64, + chain: String, + expected: u64, + }, + + #[error("rpc_lookup_failed")] + RpcLookupFailed { reason: String }, + + #[error("no_runtime_code")] + NoRuntimeCode { address: String }, + + #[error("sourcify_not_verified")] + SourcifyNotVerified { + address: String, + artifact: String, + runtime_unchecked: Option, + }, + + #[error("sourcify_runtime_not_verified")] + SourcifyRuntimeNotVerified { + address: String, + artifact: String, + runtime_unchecked: Option, + }, + + #[error("sourcify_chain_unsupported")] + SourcifyChainUnsupported { chain_id: u64 }, + + #[error("sourcify_lookup_failed")] + SourcifyLookupFailed { address: String, reason: String }, + + #[error("sourcify_response_too_large")] + SourcifyResponseTooLarge { cap_bytes: usize }, + + #[error("sourcify_malformed_response")] + SourcifyMalformedResponse { reason: String }, + + #[error("source_path_not_found")] + SourcePathNotFound { path: String }, + + #[error("source_path_ambiguous")] + SourcePathAmbiguous { matches: Vec, path: String }, + + #[error("export_destination_invalid")] + ExportDestinationInvalid { path: String }, + + #[error("export_destination_not_empty")] + ExportDestinationNotEmpty { path: String }, + + #[error("export_path_collision")] + ExportPathCollision { path: String }, + + #[error("export_write_failed")] + ExportWriteFailed { reason: String }, +} + +impl From for BeamError { + fn from(err: Error) -> Self { + match err { + Error::InvalidContractAddress { value } => Self::InvalidContractAddress { value }, + Error::RpcChainMismatch { + actual, + chain, + expected, + } => Self::ContractRpcChainMismatch { + actual, + chain, + expected, + }, + Error::RpcLookupFailed { reason } => Self::ContractRpcLookupFailed { reason }, + Error::NoRuntimeCode { address } => Self::ContractNoRuntimeCode { address }, + Error::SourcifyNotVerified { + address, + artifact, + runtime_unchecked, + } => Self::ContractSourcifyNotVerified { + address, + artifact, + runtime_check: runtime_check_message(runtime_unchecked), + }, + Error::SourcifyRuntimeNotVerified { + address, + artifact, + runtime_unchecked, + } => Self::ContractSourcifyRuntimeNotVerified { + address, + artifact, + runtime_check: runtime_check_message(runtime_unchecked), + }, + Error::SourcifyChainUnsupported { chain_id } => { + Self::ContractSourcifyChainUnsupported { chain_id } + } + Error::SourcifyLookupFailed { address, reason } => { + Self::ContractSourcifyLookupFailed { address, reason } + } + Error::SourcifyResponseTooLarge { cap_bytes } => { + Self::ContractSourcifyResponseTooLarge { cap_bytes } + } + Error::SourcifyMalformedResponse { reason } => { + Self::ContractSourcifyMalformedResponse { reason } + } + Error::SourcePathNotFound { path } => Self::ContractSourcePathNotFound { path }, + Error::SourcePathAmbiguous { path, .. } => Self::ContractSourcePathAmbiguous { path }, + Error::ExportDestinationInvalid { path } => { + Self::ContractExportDestinationInvalid { path } + } + Error::ExportDestinationNotEmpty { path } => { + Self::ContractExportDestinationNotEmpty { path } + } + Error::ExportPathCollision { path } => Self::ContractExportPathCollision { path }, + Error::ExportWriteFailed { reason } => Self::ContractExportWriteFailed { reason }, + } + } +} + +fn runtime_check_message(unchecked: Option) -> String { + match unchecked { + Some(RuntimeUnchecked { + reason: Some(reason), + }) => format!("; runtime code was not checked; RPC check failed: {reason}"), + Some(RuntimeUnchecked { reason: None }) => "; runtime code was not checked".to_owned(), + None => String::new(), + } +} diff --git a/pkg/beam-cli/src/commands/contract/export.rs b/pkg/beam-cli/src/commands/contract/export.rs new file mode 100644 index 0000000..8596b90 --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/export.rs @@ -0,0 +1,273 @@ +// lint-long-file-override allow-max-lines=300 +pub(super) mod fs_ops; + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::PathBuf, +}; + +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use sourcify_interface::ContractResponse; + +use super::{ + error::{Error, Result}, + source::require_sources, + target::InspectionTarget, +}; +use fs_ops::{ + commit_into_existing, prepare_temp_dir, validate_destination, write_failed, write_files, +}; + +#[cfg(test)] +pub(super) use fs_ops::commit_into_existing_for_test; + +const HASH_SUFFIX_HEX_LEN: usize = 16; +const MAX_SOURCE_FILENAME_BYTES: usize = 240; + +#[derive(Clone, Debug)] +pub(super) struct ExportResult { + pub(super) destination: String, + pub(super) written_files: Vec, +} + +#[derive(Clone, Debug)] +struct ExportFiles { + files: BTreeMap>, +} + +#[derive(Clone, Debug)] +struct SourceFileManifest { + path: String, + sha256: String, +} + +pub(super) fn export_bundle( + target: &InspectionTarget, + response: &ContractResponse, + destination: &str, +) -> Result { + let destination_path = PathBuf::from(destination); + validate_destination(&destination_path, destination)?; + + let export_files = build_export_files(target, response)?; + let temp_dir = prepare_temp_dir(&destination_path)?; + write_files(temp_dir.path(), &export_files.files)?; + + if destination_path.exists() { + commit_into_existing(temp_dir.path(), &destination_path, destination)?; + } else { + fs::rename(temp_dir.path(), &destination_path).map_err(write_failed)?; + } + + let mut written_files = export_files.files.keys().cloned().collect::>(); + written_files.sort(); + + Ok(ExportResult { + destination: destination.to_owned(), + written_files, + }) +} + +fn build_export_files( + target: &InspectionTarget, + response: &ContractResponse, +) -> Result { + let sources = require_sources(response.contract.sources.as_ref())?; + let mut source_filenames = BTreeSet::new(); + let mut files = BTreeMap::new(); + let mut source_files = BTreeMap::new(); + + if let Some(abi) = response.contract.abi.as_ref() { + insert_json_file(&mut files, "abi.json", &Value::Array(abi.clone()))?; + } + if let Some(metadata) = response.contract.metadata.as_ref() { + insert_value_file(&mut files, "metadata.json", metadata)?; + } + if let Some(input) = response.contract.standard_json_input.as_ref() { + insert_value_file(&mut files, "standard-json-input.json", input)?; + } + + for (source_path, source) in sources { + let output_path = flatten_source_key(source_path); + if !source_filenames.insert(output_path.clone()) { + return Err(Error::ExportPathCollision { path: output_path }); + } + let relative_path = format!("sources/{output_path}"); + let bytes = source.content.as_bytes().to_vec(); + let sha256 = sha256_hex(&bytes); + files.insert(relative_path.clone(), bytes); + source_files.insert( + source_path.to_owned(), + SourceFileManifest { + path: relative_path, + sha256, + }, + ); + } + + let manifest = build_manifest(target, response, &files, &source_files); + insert_json_file(&mut files, "sourcify.json", &manifest)?; + + Ok(ExportFiles { files }) +} + +fn build_manifest( + target: &InspectionTarget, + response: &ContractResponse, + files: &BTreeMap>, + source_files: &BTreeMap, +) -> Value { + let mut hashes = Map::new(); + for (path, bytes) in files { + hashes.insert(path.clone(), json!(sha256_hex(bytes))); + } + + let mut source_map = Map::new(); + for (source_key, source_file) in source_files { + source_map.insert( + source_key.clone(), + json!({ + "path": source_file.path.clone(), + "sha256": source_file.sha256.clone(), + }), + ); + } + + json!({ + "endpoint": response.endpoint.clone(), + "requested_fields": response.requested_fields.clone(), + "chain_id": target.chain_id, + "address": target.checksum_address.clone(), + "match": response.contract.match_state.as_str(), + "creation_match": response.contract.creation_match.map(|value| value.as_str()), + "runtime_match": response.contract.runtime_match.map(|value| value.as_str()), + "verified_at": response.contract.verified_at.clone(), + "compilation": response.contract.compilation.clone(), + "files": hashes, + "source_files": source_map, + }) +} + +pub(super) fn flatten_source_key(source_key: &str) -> String { + flatten_source_key_with_hash(source_key, &source_key_hash16(source_key)) +} + +#[cfg(test)] +pub(super) fn flatten_source_keys_for_test<'a>( + paths: impl Iterator, + hash: &str, +) -> Result> { + let mut output = BTreeMap::new(); + let mut exact = BTreeSet::new(); + + for path in paths { + let flattened = flatten_source_key_with_hash(path, hash); + if !exact.insert(flattened.clone()) { + return Err(Error::ExportPathCollision { path: flattened }); + } + output.insert(path.clone(), flattened); + } + + Ok(output) +} + +fn flatten_source_key_with_hash(source_key: &str, hash: &str) -> String { + let sanitized = sanitized_source_label(source_key); + let (stem, extension) = split_final_extension(&sanitized); + let extension = if 2 + hash.len() + extension.len() <= MAX_SOURCE_FILENAME_BYTES { + extension + } else { + "" + }; + let suffix_len = 2 + hash.len() + extension.len(); + let max_stem_len = MAX_SOURCE_FILENAME_BYTES.saturating_sub(suffix_len); + + let mut stem = stem.to_owned(); + stem.truncate(max_stem_len); + format!("{stem}--{hash}{extension}") +} + +fn sanitized_source_label(source_key: &str) -> String { + let mut output = String::new(); + let mut previous_underscore = false; + + for ch in source_key.chars() { + let mapped = if is_allowed_source_label_char(ch) { + ch + } else { + '_' + }; + if mapped == '_' { + if !previous_underscore { + output.push('_'); + previous_underscore = true; + } + } else { + output.push(mapped); + previous_underscore = false; + } + } + + let output = output.trim_matches('_'); + if output.is_empty() { + "source".to_owned() + } else { + output.to_owned() + } +} + +fn split_final_extension(filename: &str) -> (&str, &str) { + let Some(index) = filename.rfind('.') else { + return (filename, ""); + }; + if index == 0 || index + 1 == filename.len() { + return (filename, ""); + } + + (&filename[..index], &filename[index..]) +} + +fn source_key_hash16(source_key: &str) -> String { + sha256_hex(source_key.as_bytes())[..HASH_SUFFIX_HEX_LEN].to_owned() +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn is_allowed_source_label_char(ch: char) -> bool { + matches!( + ch, + 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-' | '@' | '+' + ) +} + +fn insert_json_file( + files: &mut BTreeMap>, + path: &str, + value: &Value, +) -> Result<()> { + let mut bytes = serde_json::to_vec_pretty(value).map_err(|err| Error::ExportWriteFailed { + reason: err.to_string(), + })?; + bytes.push(b'\n'); + files.insert(path.to_owned(), bytes); + + Ok(()) +} + +fn insert_value_file( + files: &mut BTreeMap>, + path: &str, + value: &Value, +) -> Result<()> { + match value { + Value::String(value) => { + files.insert(path.to_owned(), value.as_bytes().to_vec()); + Ok(()) + } + _ => insert_json_file(files, path, value), + } +} diff --git a/pkg/beam-cli/src/commands/contract/export/fs_ops.rs b/pkg/beam-cli/src/commands/contract/export/fs_ops.rs new file mode 100644 index 0000000..434f42a --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/export/fs_ops.rs @@ -0,0 +1,143 @@ +use std::{ + collections::BTreeMap, + fs::{self, DirEntry}, + path::{Path, PathBuf}, +}; + +use tempfile::Builder; + +use crate::commands::contract::error::{Error, Result}; + +pub(super) fn validate_destination(path: &Path, display: &str) -> Result<()> { + let Ok(metadata) = fs::symlink_metadata(path) else { + return Ok(()); + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(Error::ExportDestinationInvalid { + path: display.to_owned(), + }); + } + if fs::read_dir(path).map_err(write_failed)?.next().is_some() { + return Err(Error::ExportDestinationNotEmpty { + path: display.to_owned(), + }); + } + + Ok(()) +} + +pub(super) fn prepare_temp_dir(destination: &Path) -> Result { + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(write_failed)?; + Builder::new() + .prefix(".beam-contract-export-") + .tempdir_in(parent) + .map_err(write_failed) +} + +pub(super) fn write_files(root: &Path, files: &BTreeMap>) -> Result<()> { + for (path, bytes) in files { + let full_path = root.join(path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).map_err(write_failed)?; + } + fs::write(full_path, bytes).map_err(write_failed)?; + } + + Ok(()) +} + +pub(super) fn commit_into_existing( + temp_root: &Path, + destination: &Path, + display: &str, +) -> Result<()> { + commit_into_existing_with_hooks(temp_root, destination, display, None, None) +} + +#[cfg(test)] +pub(crate) fn commit_into_existing_for_test( + temp_root: &Path, + destination: &Path, + display: &str, + before_move: Option<&mut dyn FnMut(&Path)>, + after_move: Option<&mut dyn FnMut(&Path)>, +) -> Result<()> { + commit_into_existing_with_hooks(temp_root, destination, display, before_move, after_move) +} + +pub(super) fn write_failed(err: std::io::Error) -> Error { + Error::ExportWriteFailed { + reason: err.to_string(), + } +} + +fn commit_into_existing_with_hooks( + temp_root: &Path, + destination: &Path, + display: &str, + mut before_move: Option<&mut dyn FnMut(&Path)>, + mut after_move: Option<&mut dyn FnMut(&Path)>, +) -> Result<()> { + if fs::read_dir(destination) + .map_err(write_failed)? + .next() + .is_some() + { + return Err(Error::ExportDestinationNotEmpty { + path: display.to_owned(), + }); + } + + let mut entries = fs::read_dir(temp_root) + .map_err(write_failed)? + .collect::>>() + .map_err(write_failed)?; + entries.sort_by_key(DirEntry::file_name); + + let mut moved = Vec::::new(); + for entry in entries { + let target = destination.join(entry.file_name()); + if target_exists(&target).map_err(write_failed)? { + cleanup_moved(moved); + return Err(Error::ExportDestinationNotEmpty { + path: display.to_owned(), + }); + } + if let Some(before_move) = before_move.as_deref_mut() { + before_move(&target); + } + if let Err(err) = fs::rename(entry.path(), &target) { + cleanup_moved(moved); + return Err(write_failed(err)); + } + if let Some(after_move) = after_move.as_deref_mut() { + after_move(&target); + } + moved.push(target); + } + + Ok(()) +} + +fn target_exists(path: &Path) -> std::io::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + +fn cleanup_moved(moved: Vec) { + for moved_path in moved.into_iter().rev() { + let _ = remove_path(&moved_path); + } +} + +fn remove_path(path: &Path) -> std::io::Result<()> { + if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } +} diff --git a/pkg/beam-cli/src/commands/contract/info.rs b/pkg/beam-cli/src/commands/contract/info.rs new file mode 100644 index 0000000..d7e6acb --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/info.rs @@ -0,0 +1,290 @@ +// lint-long-file-override allow-max-lines=300 +use std::io::IsTerminal; + +use serde_json::{Value, json}; + +use crate::{ + human_output::sanitize_control_chars, + output::{CommandOutput, OutputMode}, +}; + +use super::{ + error::Error, + proxy::{ProxyInfo, ProxyStatus, proxy_value}, + render::common_target_value, + sourcify::{ + compilation_lines, match_summary, sourcify_not_checked_value, sourcify_record_value, + sourcify_status_value, + }, + target::{BytecodeInfo, InspectionTarget}, +}; + +pub(super) fn not_runtime_code_output( + target: &InspectionTarget, + bytecode: &BytecodeInfo, +) -> CommandOutput { + let mut value = common_target_value(target); + value.insert("kind".to_owned(), json!("no_runtime_code")); + value.insert( + "bytecode".to_owned(), + json!({ + "byte_len": bytecode.byte_len, + "code_hash": bytecode.code_hash, + }), + ); + value.insert("sourcify".to_owned(), sourcify_not_checked_value()); + value.insert( + "proxy".to_owned(), + proxy_value(&ProxyInfo { + implementations: Vec::new(), + proxy_type: None, + status: ProxyStatus::NotPerformed, + }), + ); + + CommandOutput::new( + format!( + "Chain: {} ({})\nAddress: {}\nKind: no_runtime_code\nRuntime bytecode: 0 bytes\nCode hash: {}\n\nSourcify: not checked", + target.chain, target.chain_id, target.checksum_address, bytecode.code_hash + ), + Value::Object(value), + ) + .compact(format!( + "no_runtime_code {} sourcify=not_checked", + target.checksum_address + )) + .markdown(format!( + "Chain: {} ({})\n\nAddress: `{}`\n\nKind: `no_runtime_code`\n\nRuntime bytecode: 0 bytes\n\nCode hash: `{}`\n\nSourcify: not checked", + target.chain, target.chain_id, target.checksum_address, bytecode.code_hash + )) +} + +pub(super) fn verified_output( + target: &InspectionTarget, + bytecode: &BytecodeInfo, + record: &sourcify_interface::ContractRecord, + proxy: &ProxyInfo, +) -> CommandOutput { + let mut lines = info_base_lines(target, bytecode); + lines.push(String::new()); + lines.push("Sourcify: runtime verified".to_owned()); + lines.push(format!("Match: {}", match_summary(record))); + lines.extend(compilation_lines(record)); + lines.extend(proxy_lines(proxy)); + + let mut value = common_target_value(target); + value.insert("kind".to_owned(), json!("contract")); + value.insert( + "bytecode".to_owned(), + json!({ + "byte_len": bytecode.byte_len, + "code_hash": bytecode.code_hash, + }), + ); + value.insert("sourcify".to_owned(), sourcify_record_value(record)); + value.insert("proxy".to_owned(), proxy_value(proxy)); + + CommandOutput::new(lines.join("\n"), Value::Object(value)) + .compact(format!( + "contract {} sourcify=runtime_verified", + target.checksum_address + )) + .markdown(lines.join("\n\n")) +} + +pub(super) fn sourcify_status_output( + target: &InspectionTarget, + bytecode: &BytecodeInfo, + status: &str, + error: Option<&str>, + proxy: &ProxyInfo, +) -> CommandOutput { + let mut lines = info_base_lines(target, bytecode); + lines.push(String::new()); + lines.push(format!("Sourcify: {}", human_sourcify_status(status))); + + let mut value = common_target_value(target); + value.insert("kind".to_owned(), json!("contract")); + value.insert( + "bytecode".to_owned(), + json!({ + "byte_len": bytecode.byte_len, + "code_hash": bytecode.code_hash, + }), + ); + value.insert("sourcify".to_owned(), sourcify_status_value(status, error)); + value.insert("proxy".to_owned(), proxy_value(proxy)); + + CommandOutput::new(lines.join("\n"), Value::Object(value)) + .compact(format!( + "contract {} sourcify={status}", + target.checksum_address + )) + .markdown(lines.join("\n\n")) +} + +pub(super) fn failed_proxy() -> ProxyInfo { + ProxyInfo { + implementations: Vec::new(), + proxy_type: None, + status: ProxyStatus::Failed, + } +} + +pub(super) fn source_summary_output( + target: &InspectionTarget, + record: &sourcify_interface::ContractRecord, + files: Vec, + proxy: &ProxyInfo, +) -> CommandOutput { + let mut lines = vec!["Sourcify: runtime verified".to_owned()]; + lines.extend(compilation_lines(record)); + lines.push("Files:".to_owned()); + lines.extend( + files + .iter() + .map(|file| format!(" {}", sanitize_control_chars(file))), + ); + let compact = files + .iter() + .map(|file| sanitize_control_chars(file)) + .collect::>() + .join("\n"); + + let mut value = common_target_value(target); + value.insert("sourcify".to_owned(), sourcify_record_value(record)); + value.insert("files".to_owned(), json!(files)); + value.insert("proxy".to_owned(), proxy_value(proxy)); + + CommandOutput::new(lines.join("\n"), Value::Object(value)) + .compact(compact) + .markdown(lines.join("\n\n")) +} + +pub(super) fn error_code(err: &Error) -> &'static str { + match err { + Error::SourcifyLookupFailed { .. } => "sourcify_lookup_failed", + Error::SourcifyResponseTooLarge { .. } => "sourcify_response_too_large", + Error::SourcifyMalformedResponse { .. } => "sourcify_malformed_response", + _ => "sourcify_lookup_failed", + } +} + +pub(super) fn print_proxy_tip( + mode: OutputMode, + artifact: &str, + proxy: &ProxyInfo, + destination: Option<&str>, +) { + if mode != OutputMode::Default || !std::io::stderr().is_terminal() { + return; + } + if proxy.status != ProxyStatus::Resolved { + return; + } + for implementation in &proxy.implementations { + match destination { + Some(destination) => eprintln!( + "Tip: this address appears to be a proxy. Implementation: {}\n To export the implementation: beam contract export {} {destination}", + implementation.address, implementation.address + ), + None => eprintln!( + "Tip: this address appears to be a proxy. Implementation: {}\n To fetch the implementation {artifact}: beam contract {} {}", + implementation.address, + artifact.to_ascii_lowercase(), + implementation.address + ), + } + } +} + +pub(super) fn print_sourcify_miss_tip( + mode: OutputMode, + target: &InspectionTarget, + err: &Error, + runtime_checked: bool, + rpc_failure: Option<&str>, +) { + if mode != OutputMode::Default || !std::io::stderr().is_terminal() { + return; + } + + match err { + Error::SourcifyNotVerified { artifact, .. } if artifact == "ABI" => { + eprintln!("ABI not found on Sourcify for {}", target.checksum_address); + } + Error::SourcifyNotVerified { .. } => { + eprintln!( + "Source not found on Sourcify for {}", + target.checksum_address + ); + } + Error::SourcifyRuntimeNotVerified { artifact, .. } if artifact == "ABI" => { + eprintln!("ABI not found on Sourcify for {}", target.checksum_address); + } + Error::SourcifyRuntimeNotVerified { artifact, .. } => { + eprintln!( + "{artifact} not runtime-verified on Sourcify for {}", + target.checksum_address + ); + } + _ => return, + } + + if runtime_checked { + eprintln!( + "Tip: deployed bytecode may still be available with:\n beam contract bytecode {}", + target.checksum_address + ); + } else { + eprintln!("Runtime code was not checked."); + if let Some(reason) = rpc_failure { + eprintln!("RPC check failed: {reason}"); + } + } +} + +fn info_base_lines(target: &InspectionTarget, bytecode: &BytecodeInfo) -> Vec { + vec![ + format!("Chain: {} ({})", target.chain, target.chain_id), + format!("Address: {}", target.checksum_address), + "Kind: contract".to_owned(), + format!("Runtime bytecode: {} bytes", bytecode.byte_len), + format!("Code hash: {}", bytecode.code_hash), + ] +} + +fn proxy_lines(proxy: &ProxyInfo) -> Vec { + let mut lines = vec![String::new()]; + match proxy.status { + ProxyStatus::Failed => return vec!["Proxy: lookup failed".to_owned()], + ProxyStatus::NotPerformed => return vec!["Proxy: not checked".to_owned()], + ProxyStatus::NotProxy => return vec!["Proxy: no".to_owned()], + ProxyStatus::Resolved => lines.push("Proxy: yes".to_owned()), + } + if let Some(proxy_type) = proxy.proxy_type.as_ref() { + lines.push(format!( + "Proxy type: {}", + sanitize_control_chars(proxy_type) + )); + } + for implementation in &proxy.implementations { + lines.push(format!("Implementation: {}", implementation.address)); + if let Some(name) = implementation.name.as_ref() { + lines.push(format!( + "Implementation name: {}", + sanitize_control_chars(name) + )); + } + } + lines +} + +fn human_sourcify_status(status: &str) -> &'static str { + match status { + "not_verified" | "runtime_not_verified" => "not runtime verified", + "unsupported_chain" => "unsupported chain", + "lookup_failed" => "lookup failed", + _ => "not checked", + } +} diff --git a/pkg/beam-cli/src/commands/contract/mod.rs b/pkg/beam-cli/src/commands/contract/mod.rs new file mode 100644 index 0000000..3f05aad --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/mod.rs @@ -0,0 +1,274 @@ +// lint-long-file-override allow-max-lines=300 +mod artifact; +mod error; +mod export; +mod info; +mod proxy; +mod render; +mod source; +mod sourcify; +mod target; + +#[cfg(test)] +mod tests; + +use serde_json::Value; +use sourcify_client_reqwest::{SourcifyReqwestClient, SourcifyReqwestClientOptions}; +use sourcify_interface::SourcifyClient; +use std::io::IsTerminal; + +use crate::{ + cli::{ + ContractAction, ContractAddressArgs, ContractBytecodeArgs, ContractExportArgs, + ContractSourceArgs, + }, + error::{Error as BeamError, Result}, + human_output::sanitize_control_chars, + output::{OutputMode, with_loading}, + runtime::BeamApp, +}; + +use self::{ + artifact::runtime_verified_artifact, + error::Error, + export::export_bundle, + info::{ + error_code, failed_proxy, not_runtime_code_output, print_proxy_tip, source_summary_output, + sourcify_status_output, verified_output, + }, + proxy::parse_proxy, + render::{abi_output, bytecode_output, export_output, print_raw_text, source_file_output}, + source::{match_source_path, require_sources, source_paths}, + sourcify::{ + ABI_CAP_BYTES, Artifact, EXPORT_CAP_BYTES, INFO_CAP_BYTES, SOURCE_CAP_BYTES, abi_fields, + export_fields, info_fields, lookup_contract, source_fields, + }, + target::{build_target, fetch_bytecode, parse_block, required_rpc_client}, +}; + +pub async fn run(app: &BeamApp, action: ContractAction) -> Result<()> { + let client = + SourcifyReqwestClient::new(SourcifyReqwestClientOptions::Public).map_err(|err| { + BeamError::ContractSourcifyLookupFailed { + address: "unknown".to_owned(), + reason: err.to_string(), + } + })?; + run_with_client(app, action, &client).await +} + +async fn run_with_client( + app: &BeamApp, + action: ContractAction, + sourcify_client: &dyn SourcifyClient, +) -> Result<()> { + match action { + ContractAction::Info(args) => run_info(app, args, sourcify_client).await, + ContractAction::Bytecode(args) => run_bytecode(app, args).await, + ContractAction::Abi(args) => run_abi(app, args, sourcify_client).await, + ContractAction::Source(args) => run_source(app, args, sourcify_client).await, + ContractAction::Export(args) => run_export(app, args, sourcify_client).await, + } +} + +async fn run_info( + app: &BeamApp, + args: ContractAddressArgs, + sourcify_client: &dyn SourcifyClient, +) -> Result<()> { + let target = build_target(app, &args.address).await?; + let client = required_rpc_client(app, &target).await?; + let bytecode = with_loading(app.output_mode, "Fetching contract bytecode...", async { + fetch_bytecode(&client, target.address, web3::types::BlockNumber::Latest) + .await + .map_err(BeamError::from) + }) + .await?; + + if bytecode.byte_len == 0 { + return not_runtime_code_output(&target, &bytecode).print(app.output_mode); + } + + let response = lookup_contract(sourcify_client, &target, info_fields(), INFO_CAP_BYTES).await; + let output = match response { + Ok(response) if response.contract.runtime_match.is_some() => { + let proxy = parse_proxy(&response.contract, true); + verified_output(&target, &bytecode, &response.contract, &proxy) + } + Ok(response) => { + let proxy = parse_proxy(&response.contract, true); + sourcify_status_output(&target, &bytecode, "runtime_not_verified", None, &proxy) + } + Err(Error::SourcifyNotVerified { .. }) => { + sourcify_status_output(&target, &bytecode, "not_verified", None, &failed_proxy()) + } + Err(Error::SourcifyRuntimeNotVerified { .. }) => sourcify_status_output( + &target, + &bytecode, + "runtime_not_verified", + None, + &failed_proxy(), + ), + Err(Error::SourcifyChainUnsupported { .. }) => sourcify_status_output( + &target, + &bytecode, + "unsupported_chain", + None, + &failed_proxy(), + ), + Err(err @ Error::SourcifyLookupFailed { .. }) + | Err(err @ Error::SourcifyResponseTooLarge { .. }) + | Err(err @ Error::SourcifyMalformedResponse { .. }) => { + let proxy = failed_proxy(); + sourcify_status_output( + &target, + &bytecode, + "lookup_failed", + Some(error_code(&err)), + &proxy, + ) + } + Err(err) => return Err(err.into()), + }; + + output.print(app.output_mode) +} + +async fn run_bytecode(app: &BeamApp, args: ContractBytecodeArgs) -> Result<()> { + let target = build_target(app, &args.address).await?; + let block = parse_block(args.block.as_deref())?; + let client = required_rpc_client(app, &target).await?; + let bytecode = with_loading(app.output_mode, "Fetching contract bytecode...", async { + fetch_bytecode(&client, target.address, block.number) + .await + .map_err(BeamError::from) + }) + .await?; + + bytecode_output(&target, &block.selector, &bytecode).print(app.output_mode) +} + +async fn run_abi( + app: &BeamApp, + args: ContractAddressArgs, + sourcify_client: &dyn SourcifyClient, +) -> Result<()> { + let target = build_target(app, &args.address).await?; + let response = runtime_verified_artifact( + app, + sourcify_client, + &target, + abi_fields(), + ABI_CAP_BYTES, + Artifact::Abi, + ) + .await?; + let abi = response + .contract + .abi + .clone() + .ok_or_else(|| Error::SourcifyMalformedResponse { + reason: "abi field is missing".to_owned(), + })?; + let abi_text = serde_json::to_string_pretty(&Value::Array(abi.clone())).map_err(|err| { + Error::SourcifyMalformedResponse { + reason: err.to_string(), + } + })?; + let proxy = parse_proxy(&response.contract, true); + print_proxy_tip(app.output_mode, "abi", &proxy, None); + + abi_output(&target, &abi_text, abi, &response.contract, &proxy).print(app.output_mode) +} + +async fn run_source( + app: &BeamApp, + args: ContractSourceArgs, + sourcify_client: &dyn SourcifyClient, +) -> Result<()> { + let target = build_target(app, &args.address).await?; + let response = runtime_verified_artifact( + app, + sourcify_client, + &target, + source_fields(), + SOURCE_CAP_BYTES, + Artifact::Source, + ) + .await?; + let sources = require_sources(response.contract.sources.as_ref())?; + let proxy = parse_proxy(&response.contract, true); + + let Some(source_path) = args.source_path.as_ref() else { + print_proxy_tip(app.output_mode, "source", &proxy, None); + return source_summary_output(&target, &response.contract, source_paths(sources), &proxy) + .print(app.output_mode); + }; + + let matched = match match_source_path(sources, source_path) { + Ok(matched) => matched, + Err(err @ Error::SourcePathAmbiguous { .. }) => { + print_source_path_ambiguity(app.output_mode, &err); + return Err(err.into()); + } + Err(err) => return Err(err.into()), + }; + print_proxy_tip(app.output_mode, "source", &proxy, None); + match app.output_mode { + OutputMode::Default | OutputMode::Compact => { + print_raw_text(app.output_mode, matched.content) + } + OutputMode::Quiet => Ok(()), + OutputMode::Json | OutputMode::Yaml | OutputMode::Markdown => source_file_output( + &target, + &matched.path, + &matched.kind, + matched.content, + &response.contract, + &proxy, + ) + .print(app.output_mode), + } +} + +async fn run_export( + app: &BeamApp, + args: ContractExportArgs, + sourcify_client: &dyn SourcifyClient, +) -> Result<()> { + let target = build_target(app, &args.address).await?; + let response = runtime_verified_artifact( + app, + sourcify_client, + &target, + export_fields(), + EXPORT_CAP_BYTES, + Artifact::Export, + ) + .await?; + let proxy = parse_proxy(&response.contract, true); + let exported = export_bundle(&target, &response, &args.destination)?; + print_proxy_tip(app.output_mode, "export", &proxy, Some(&args.destination)); + + export_output( + &target, + &exported.destination, + &exported.written_files, + &response.contract, + &proxy, + ) + .print(app.output_mode) +} + +fn print_source_path_ambiguity(mode: OutputMode, err: &Error) { + if mode != OutputMode::Default || !std::io::stderr().is_terminal() { + return; + } + let Error::SourcePathAmbiguous { matches, .. } = err else { + return; + }; + eprintln!("Matching source paths:"); + for path in matches { + eprintln!(" {}", sanitize_control_chars(path)); + } +} diff --git a/pkg/beam-cli/src/commands/contract/proxy.rs b/pkg/beam-cli/src/commands/contract/proxy.rs new file mode 100644 index 0000000..88d21ca --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/proxy.rs @@ -0,0 +1,147 @@ +use serde_json::{Map, Value, json}; +use sourcify_interface::ContractRecord; + +use super::target::checksum_address; + +#[derive(Clone, Debug)] +pub(super) struct ProxyImplementation { + pub(super) address: String, + pub(super) name: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum ProxyStatus { + Failed, + NotPerformed, + NotProxy, + Resolved, +} + +#[derive(Clone, Debug)] +pub(super) struct ProxyInfo { + pub(super) implementations: Vec, + pub(super) proxy_type: Option, + pub(super) status: ProxyStatus, +} + +pub(super) fn parse_proxy(record: &ContractRecord, requested: bool) -> ProxyInfo { + if !requested { + return ProxyInfo { + implementations: Vec::new(), + proxy_type: None, + status: ProxyStatus::NotPerformed, + }; + } + + let Some(value) = record.proxy_resolution.as_ref() else { + return not_proxy(); + }; + if value.is_null() { + return not_proxy(); + } + + parse_proxy_value(value).unwrap_or_else(|_| ProxyInfo { + implementations: Vec::new(), + proxy_type: None, + status: ProxyStatus::Failed, + }) +} + +pub(super) fn proxy_value(proxy: &ProxyInfo) -> Value { + let mut object = Map::new(); + object.insert("status".to_owned(), json!(proxy.status.as_str())); + object.insert( + "implementations".to_owned(), + Value::Array( + proxy + .implementations + .iter() + .map(|implementation| { + let mut value = Map::new(); + value.insert("address".to_owned(), json!(implementation.address)); + if let Some(name) = implementation.name.as_ref() { + value.insert("name".to_owned(), json!(name)); + } + Value::Object(value) + }) + .collect(), + ), + ); + if let Some(proxy_type) = proxy.proxy_type.as_ref() { + object.insert("proxy_type".to_owned(), json!(proxy_type)); + } + + Value::Object(object) +} + +impl ProxyStatus { + pub(super) fn as_str(&self) -> &'static str { + match self { + Self::Failed => "failed", + Self::NotPerformed => "not_performed", + Self::NotProxy => "not_proxy", + Self::Resolved => "resolved", + } + } +} + +fn parse_proxy_value(value: &Value) -> std::result::Result { + let object = value.as_object().ok_or(())?; + let is_proxy = object.get("isProxy").and_then(Value::as_bool).ok_or(())?; + if !is_proxy { + return Ok(not_proxy()); + } + + let implementations = object + .get("implementations") + .and_then(Value::as_array) + .ok_or(())? + .iter() + .map(parse_proxy_implementation) + .collect::, _>>()?; + let proxy_type = object + .get("proxyType") + .or_else(|| object.get("type")) + .and_then(Value::as_str) + .map(str::to_owned); + + Ok(ProxyInfo { + implementations, + proxy_type, + status: ProxyStatus::Resolved, + }) +} + +fn parse_proxy_implementation(value: &Value) -> std::result::Result { + let object = value.as_object().ok_or(())?; + let address = object.get("address").and_then(Value::as_str).ok_or(())?; + let address = parse_proxy_address(address)?; + let name = match object.get("name") { + Some(Value::String(name)) => Some(name.clone()), + Some(Value::Null) | None => None, + Some(_) => return Err(()), + }; + + Ok(ProxyImplementation { address, name }) +} + +fn parse_proxy_address(value: &str) -> std::result::Result { + if value.len() != 42 + || !value.starts_with("0x") + || !value[2..].bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(()); + } + let bytes = hex::decode(&value[2..]).map_err(|_| ())?; + let address = contracts::Address::from_slice(&bytes); + + Ok(checksum_address(address)) +} + +fn not_proxy() -> ProxyInfo { + ProxyInfo { + implementations: Vec::new(), + proxy_type: None, + status: ProxyStatus::NotProxy, + } +} diff --git a/pkg/beam-cli/src/commands/contract/render.rs b/pkg/beam-cli/src/commands/contract/render.rs new file mode 100644 index 0000000..b16b1d3 --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/render.rs @@ -0,0 +1,129 @@ +use std::io::Write; + +use contextful::ResultContextExt; +use serde_json::{Map, Value, json}; + +use crate::{ + error::Result, + output::{CommandOutput, OutputMode}, +}; + +use super::{ + proxy::{ProxyInfo, proxy_value}, + source::SourceMatchKind, + sourcify::sourcify_record_value, + target::{BytecodeInfo, InspectionTarget}, +}; + +pub(super) fn common_target_value(target: &InspectionTarget) -> Map { + let mut object = Map::new(); + object.insert("chain".to_owned(), json!(target.chain)); + object.insert("chain_id".to_owned(), json!(target.chain_id)); + object.insert("address".to_owned(), json!(target.checksum_address)); + object.insert("input_address".to_owned(), json!(target.input_address)); + object +} + +pub(super) fn print_raw_text(mode: OutputMode, text: &str) -> Result<()> { + match mode { + OutputMode::Default | OutputMode::Compact => { + let mut stdout = std::io::stdout().lock(); + stdout + .write_all(text.as_bytes()) + .context("write beam contract raw output")?; + if !text.ends_with('\n') { + stdout + .write_all(b"\n") + .context("write beam contract raw output newline")?; + } + stdout.flush().context("flush beam contract raw output")?; + } + OutputMode::Quiet => {} + OutputMode::Json | OutputMode::Yaml | OutputMode::Markdown => unreachable!(), + } + + Ok(()) +} + +pub(super) fn bytecode_output( + target: &InspectionTarget, + block: &str, + bytecode: &BytecodeInfo, +) -> CommandOutput { + let mut value = common_target_value(target); + value.insert("block".to_owned(), json!(block)); + value.insert("bytecode".to_owned(), json!(bytecode.hex)); + value.insert("byte_len".to_owned(), json!(bytecode.byte_len)); + value.insert("code_hash".to_owned(), json!(bytecode.code_hash)); + value.insert( + "proxy".to_owned(), + json!({ + "status": "not_performed", + "implementations": [], + }), + ); + + CommandOutput::new(bytecode.hex.clone(), Value::Object(value)) + .compact(bytecode.hex.clone()) + .markdown(format!("```text\n{}\n```", bytecode.hex)) +} + +pub(super) fn abi_output( + target: &InspectionTarget, + abi_text: &str, + abi: Vec, + record: &sourcify_interface::ContractRecord, + proxy: &ProxyInfo, +) -> CommandOutput { + let mut value = common_target_value(target); + value.insert("abi".to_owned(), Value::Array(abi)); + value.insert("sourcify".to_owned(), sourcify_record_value(record)); + value.insert("proxy".to_owned(), proxy_value(proxy)); + + CommandOutput::new(abi_text.to_owned(), Value::Object(value)) + .compact(abi_text.to_owned()) + .markdown(format!("```json\n{abi_text}\n```")) +} + +pub(super) fn source_file_output( + target: &InspectionTarget, + path: &str, + kind: &SourceMatchKind, + content: &str, + record: &sourcify_interface::ContractRecord, + proxy: &ProxyInfo, +) -> CommandOutput { + let mut value = common_target_value(target); + value.insert("path".to_owned(), json!(path)); + value.insert("matched_by".to_owned(), json!(kind.as_str())); + value.insert("content".to_owned(), json!(content)); + value.insert("sourcify".to_owned(), sourcify_record_value(record)); + value.insert("proxy".to_owned(), proxy_value(proxy)); + + CommandOutput::new(content.to_owned(), Value::Object(value)) + .compact(content.to_owned()) + .markdown(format!("```text\n{content}\n```")) +} + +pub(super) fn export_output( + target: &InspectionTarget, + destination: &str, + written_files: &[String], + record: &sourcify_interface::ContractRecord, + proxy: &ProxyInfo, +) -> CommandOutput { + let mut value = common_target_value(target); + value.insert("destination".to_owned(), json!(destination)); + value.insert("sourcify".to_owned(), sourcify_record_value(record)); + value.insert("written_files".to_owned(), json!(written_files)); + value.insert("proxy".to_owned(), proxy_value(proxy)); + + let default = format!( + "Exported verified contract bundle to {destination}\nFiles: {}", + written_files.len() + ); + + CommandOutput::new(default.clone(), Value::Object(value)) + .compact(destination.to_owned()) + .markdown(default) +} diff --git a/pkg/beam-cli/src/commands/contract/source.rs b/pkg/beam-cli/src/commands/contract/source.rs new file mode 100644 index 0000000..fecd6fa --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/source.rs @@ -0,0 +1,85 @@ +use std::collections::BTreeMap; + +use sourcify_interface::SourceFile; + +use super::error::{Error, Result}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum SourceMatchKind { + Basename, + Exact, +} + +#[derive(Clone, Debug)] +pub(super) struct MatchedSource<'a> { + pub(super) content: &'a str, + pub(super) kind: SourceMatchKind, + pub(super) path: String, +} + +pub(super) fn require_sources( + sources: Option<&BTreeMap>, +) -> Result<&BTreeMap> { + let Some(sources) = sources else { + return Err(Error::SourcifyMalformedResponse { + reason: "sources field is missing".to_owned(), + }); + }; + if sources.is_empty() { + return Err(Error::SourcifyMalformedResponse { + reason: "sources field is empty".to_owned(), + }); + } + + Ok(sources) +} + +pub(super) fn source_paths(sources: &BTreeMap) -> Vec { + sources.keys().cloned().collect() +} + +pub(super) fn match_source_path<'a>( + sources: &'a BTreeMap, + requested_path: &str, +) -> Result> { + if let Some(source) = sources.get(requested_path) { + return Ok(MatchedSource { + content: &source.content, + kind: SourceMatchKind::Exact, + path: requested_path.to_owned(), + }); + } + + let matches = sources + .iter() + .filter(|(path, _)| basename(path) == requested_path) + .collect::>(); + + match matches.as_slice() { + [] => Err(Error::SourcePathNotFound { + path: requested_path.to_owned(), + }), + [(path, source)] => Ok(MatchedSource { + content: &source.content, + kind: SourceMatchKind::Basename, + path: (*path).clone(), + }), + _ => Err(Error::SourcePathAmbiguous { + matches: matches.iter().map(|(path, _)| (*path).clone()).collect(), + path: requested_path.to_owned(), + }), + } +} + +impl SourceMatchKind { + pub(super) fn as_str(&self) -> &'static str { + match self { + Self::Basename => "basename", + Self::Exact => "exact", + } + } +} + +fn basename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} diff --git a/pkg/beam-cli/src/commands/contract/sourcify.rs b/pkg/beam-cli/src/commands/contract/sourcify.rs new file mode 100644 index 0000000..3a496a4 --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/sourcify.rs @@ -0,0 +1,223 @@ +// lint-long-file-override allow-max-lines=250 +use serde_json::{Map, Value, json}; +use sourcify_interface::{ + CompilationSummary, ContractField, ContractLookup, ContractRecord, ContractResponse, + Error as SourcifyError, MatchState, SourcifyClient, +}; + +use crate::human_output::sanitize_control_chars; + +use super::{ + error::{Error, Result}, + target::InspectionTarget, +}; + +#[derive(Clone, Copy, Debug)] +pub(super) enum Artifact { + Abi, + Source, + Export, +} + +pub(super) const INFO_CAP_BYTES: usize = 10 * 1024 * 1024; +pub(super) const ABI_CAP_BYTES: usize = 10 * 1024 * 1024; +pub(super) const SOURCE_CAP_BYTES: usize = 100 * 1024 * 1024; +pub(super) const EXPORT_CAP_BYTES: usize = 100 * 1024 * 1024; + +pub(super) fn info_fields() -> Vec { + vec![ + ContractField::CreationMatch, + ContractField::RuntimeMatch, + ContractField::VerifiedAt, + ContractField::Compilation, + ContractField::ProxyResolution, + ] +} + +pub(super) fn abi_fields() -> Vec { + vec![ + ContractField::Abi, + ContractField::ProxyResolution, + ContractField::Compilation, + ContractField::CreationMatch, + ContractField::RuntimeMatch, + ContractField::VerifiedAt, + ] +} + +pub(super) fn source_fields() -> Vec { + vec![ + ContractField::Sources, + ContractField::Metadata, + ContractField::Compilation, + ContractField::ProxyResolution, + ContractField::CreationMatch, + ContractField::RuntimeMatch, + ContractField::VerifiedAt, + ] +} + +pub(super) fn export_fields() -> Vec { + vec![ + ContractField::Abi, + ContractField::Sources, + ContractField::Metadata, + ContractField::StandardJsonInput, + ContractField::Compilation, + ContractField::ProxyResolution, + ContractField::CreationMatch, + ContractField::RuntimeMatch, + ContractField::VerifiedAt, + ] +} + +pub(super) async fn lookup_contract( + client: &dyn SourcifyClient, + target: &InspectionTarget, + fields: Vec, + cap_bytes: usize, +) -> Result { + client + .contract(&ContractLookup { + chain_id: target.chain_id, + address: target.checksum_address.clone(), + fields, + response_cap_bytes: cap_bytes, + }) + .await + .map_err(|err| map_sourcify_error(err, target, cap_bytes)) +} + +pub(super) fn sourcify_not_checked_value() -> Value { + json!({ + "status": "not_checked", + "checked": false, + "verified": false, + }) +} + +pub(super) fn sourcify_record_value(record: &ContractRecord) -> Value { + let mut object = Map::new(); + object.insert("status".to_owned(), json!("runtime_verified")); + object.insert("checked".to_owned(), json!(true)); + object.insert("verified".to_owned(), json!(true)); + object.insert("match".to_owned(), json!(record.match_state.as_str())); + object.insert( + "creation_match".to_owned(), + match record.creation_match { + Some(value) => json!(value.as_str()), + None => Value::Null, + }, + ); + object.insert( + "runtime_match".to_owned(), + match record.runtime_match { + Some(value) => json!(value.as_str()), + None => Value::Null, + }, + ); + + insert_optional_string(&mut object, "verified_at", record.verified_at.as_ref()); + if let Some(compilation) = record.compilation.as_ref() { + insert_compilation(&mut object, compilation); + } + + Value::Object(object) +} + +pub(super) fn sourcify_status_value(status: &str, error: Option<&str>) -> Value { + let mut object = Map::new(); + object.insert("status".to_owned(), json!(status)); + object.insert("checked".to_owned(), json!(true)); + object.insert("verified".to_owned(), json!(false)); + if let Some(error) = error { + object.insert("error".to_owned(), json!(error)); + } + + Value::Object(object) +} + +pub(super) fn artifact_label(artifact: Artifact) -> &'static str { + match artifact { + Artifact::Abi => "ABI", + Artifact::Source => "Source", + Artifact::Export => "Source bundle", + } +} + +pub(super) fn match_summary(record: &ContractRecord) -> String { + format!( + "runtime {}, creation {}", + match_label(record.runtime_match), + match_label(record.creation_match), + ) +} + +pub(super) fn compilation_lines(record: &ContractRecord) -> Vec { + let mut lines = Vec::new(); + if let Some(compilation) = record.compilation.as_ref() { + if let Some(contract_name) = compilation.contract_name.as_ref() { + lines.push(format!( + "Contract: {}", + sanitize_control_chars(contract_name) + )); + } + if let Some(language) = compilation.language.as_ref() { + lines.push(format!("Language: {}", sanitize_control_chars(language))); + } + if let Some(compiler) = compilation.compiler.as_ref() { + lines.push(format!("Compiler: {}", sanitize_control_chars(compiler))); + } + } + if let Some(verified_at) = record.verified_at.as_ref() { + lines.push(format!( + "Verified at: {}", + sanitize_control_chars(verified_at) + )); + } + + lines +} + +fn map_sourcify_error(err: SourcifyError, target: &InspectionTarget, cap_bytes: usize) -> Error { + match err { + SourcifyError::NotVerified => Error::SourcifyNotVerified { + address: target.checksum_address.clone(), + artifact: "artifact".to_owned(), + runtime_unchecked: None, + }, + SourcifyError::ChainUnsupported { chain_id } => { + Error::SourcifyChainUnsupported { chain_id } + } + SourcifyError::LookupFailed { reason } => Error::SourcifyLookupFailed { + address: target.checksum_address.clone(), + reason, + }, + SourcifyError::ResponseTooLarge { .. } => Error::SourcifyResponseTooLarge { cap_bytes }, + SourcifyError::MalformedResponse { reason } => Error::SourcifyMalformedResponse { reason }, + SourcifyError::Internal(internal) => Error::SourcifyLookupFailed { + address: target.checksum_address.clone(), + reason: internal.to_string(), + }, + } +} + +fn insert_compilation(object: &mut Map, compilation: &CompilationSummary) { + insert_optional_string(object, "contract_name", compilation.contract_name.as_ref()); + insert_optional_string(object, "language", compilation.language.as_ref()); + insert_optional_string(object, "compiler", compilation.compiler.as_ref()); +} + +fn insert_optional_string(object: &mut Map, key: &str, value: Option<&String>) { + if let Some(value) = value { + object.insert(key.to_owned(), json!(value)); + } +} + +fn match_label(value: Option) -> &'static str { + match value { + Some(MatchState::ExactMatch) => "exact match", + Some(MatchState::Match) => "match", + None => "not verified", + } +} diff --git a/pkg/beam-cli/src/commands/contract/target.rs b/pkg/beam-cli/src/commands/contract/target.rs new file mode 100644 index 0000000..440e883 --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/target.rs @@ -0,0 +1,249 @@ +// lint-long-file-override allow-max-lines=250 +use contracts::{Address, Client}; +use web3::{ + signing::keccak256, + types::{BlockNumber, Bytes}, +}; + +use crate::{chains::ChainEntry, error::Error as BeamError, runtime::BeamApp}; + +use super::error::{Error, Result}; + +#[derive(Clone, Debug)] +pub(super) struct InspectionTarget { + pub(super) address: Address, + pub(super) chain: String, + pub(super) chain_id: u64, + pub(super) checksum_address: String, + pub(super) entry: ChainEntry, + pub(super) input_address: String, +} + +#[derive(Clone, Debug)] +pub(super) struct BytecodeInfo { + pub(super) byte_len: usize, + pub(super) code_hash: String, + pub(super) hex: String, +} + +#[derive(Clone, Debug)] +pub(super) struct ContractBlock { + pub(super) number: BlockNumber, + pub(super) selector: String, +} + +#[derive(Clone, Debug)] +pub(super) enum RpcProbe { + RuntimeCode, + NoRuntimeCode, + Unchecked { reason: Option }, +} + +pub(super) async fn build_target(app: &BeamApp, input_address: &str) -> Result { + let entry = app + .active_chain_entry() + .await + .map_err(map_rpc_lookup_error)?; + build_target_from_entry(entry, input_address) +} + +pub(super) fn build_target_from_entry( + entry: ChainEntry, + input_address: &str, +) -> Result { + let address = parse_literal_address(input_address)?; + let checksum_address = checksum_address(address); + + Ok(InspectionTarget { + address, + chain: entry.key.clone(), + chain_id: entry.chain_id, + checksum_address, + entry, + input_address: input_address.to_owned(), + }) +} + +pub(super) async fn required_rpc_client( + app: &BeamApp, + target: &InspectionTarget, +) -> Result { + let rpc_url = app + .active_rpc_url_for_chain(&target.entry) + .await + .map_err(map_rpc_lookup_error)?; + let client = Client::try_new(&rpc_url, None).map_err(|_| Error::RpcLookupFailed { + reason: "invalid RPC URL".to_owned(), + })?; + validate_rpc_chain_id(&client, target).await?; + + Ok(client) +} + +pub(super) async fn optional_rpc_probe( + app: &BeamApp, + target: &InspectionTarget, +) -> Result { + let rpc_url = match app.active_rpc_url_for_chain(&target.entry).await { + Ok(rpc_url) => rpc_url, + Err(BeamError::NoRpcConfigured { .. }) => { + return Ok(RpcProbe::Unchecked { reason: None }); + } + Err(err) => { + return Ok(RpcProbe::Unchecked { + reason: Some(err.to_string()), + }); + } + }; + let Ok(client) = Client::try_new(&rpc_url, None) else { + return Ok(RpcProbe::Unchecked { + reason: Some("invalid RPC URL".to_owned()), + }); + }; + if let Err(err) = validate_rpc_chain_id(&client, target).await { + return rpc_probe_error(err); + } + let code = match fetch_bytecode(&client, target.address, BlockNumber::Latest).await { + Ok(code) => code, + Err(err) => return rpc_probe_error(err), + }; + if code.byte_len == 0 { + return Ok(RpcProbe::NoRuntimeCode); + } + + Ok(RpcProbe::RuntimeCode) +} + +fn rpc_probe_error(err: Error) -> Result { + match err { + Error::RpcChainMismatch { + actual, + chain, + expected, + } => Err(Error::RpcChainMismatch { + actual, + chain, + expected, + }), + Error::RpcLookupFailed { reason } => Ok(RpcProbe::Unchecked { + reason: Some(reason), + }), + err => Ok(RpcProbe::Unchecked { + reason: Some(err.to_string()), + }), + } +} + +pub(super) async fn validate_rpc_chain_id( + client: &Client, + target: &InspectionTarget, +) -> Result<()> { + let actual = client + .chain_id_contracts() + .await + .map_err(|err| Error::RpcLookupFailed { + reason: err.to_string(), + })? + .low_u64(); + if actual != target.chain_id { + return Err(Error::RpcChainMismatch { + actual, + chain: target.chain.clone(), + expected: target.chain_id, + }); + } + + Ok(()) +} + +pub(super) async fn fetch_bytecode( + client: &Client, + address: Address, + block: BlockNumber, +) -> Result { + let bytes = client + .client() + .eth() + .code(address, Some(block)) + .await + .map_err(|err| Error::RpcLookupFailed { + reason: err.to_string(), + })?; + + Ok(bytecode_info(bytes)) +} + +pub(super) fn parse_block(value: Option<&str>) -> Result { + let value = value.unwrap_or("latest"); + let number = match value { + "latest" => BlockNumber::Latest, + "pending" => BlockNumber::Pending, + "safe" => BlockNumber::Safe, + "finalized" => BlockNumber::Finalized, + value => { + let number = value.parse::().map_err(|_| Error::RpcLookupFailed { + reason: format!("invalid block selector: {value}"), + })?; + BlockNumber::Number(number.into()) + } + }; + + Ok(ContractBlock { + number, + selector: value.to_owned(), + }) +} + +pub(super) fn bytecode_info(bytes: Bytes) -> BytecodeInfo { + let raw = bytes.0; + BytecodeInfo { + byte_len: raw.len(), + code_hash: format!("0x{}", hex::encode(keccak256(&raw))), + hex: format!("0x{}", hex::encode(raw)), + } +} + +fn parse_literal_address(value: &str) -> Result
{ + if value.len() != 42 + || !value.starts_with("0x") + || !value[2..].bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(Error::InvalidContractAddress { + value: value.to_owned(), + }); + } + let bytes = hex::decode(&value[2..]).map_err(|_| Error::InvalidContractAddress { + value: value.to_owned(), + })?; + + Ok(Address::from_slice(&bytes)) +} + +pub(super) fn checksum_address(address: Address) -> String { + let lower = hex::encode(address.as_bytes()); + let hash = keccak256(lower.as_bytes()); + let mut checksum = String::with_capacity(42); + checksum.push_str("0x"); + + for (index, byte) in lower.bytes().enumerate() { + let hash_byte = hash[index / 2]; + let nibble = if index.is_multiple_of(2) { + hash_byte >> 4 + } else { + hash_byte & 0x0f + }; + if byte.is_ascii_alphabetic() && nibble >= 8 { + checksum.push((byte as char).to_ascii_uppercase()); + } else { + checksum.push(byte as char); + } + } + + checksum +} + +fn map_rpc_lookup_error(err: BeamError) -> Error { + Error::RpcLookupFailed { + reason: err.to_string(), + } +} diff --git a/pkg/beam-cli/src/commands/contract/tests.rs b/pkg/beam-cli/src/commands/contract/tests.rs new file mode 100644 index 0000000..a6a7cb3 --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/tests.rs @@ -0,0 +1,265 @@ +// lint-long-file-override allow-max-lines=280 +mod export; + +use serde_json::json; +use sourcify_interface::{ + CompilationSummary, ContractRecord, ContractResponse, MatchState, SourceFile, +}; + +use super::{ + error::{Error, RuntimeUnchecked}, + info::{failed_proxy, source_summary_output, sourcify_status_output, verified_output}, + proxy::{ProxyImplementation, ProxyInfo, ProxyStatus}, + source::{SourceMatchKind, match_source_path}, + target::{BytecodeInfo, build_target_from_entry}, +}; +use crate::{chains::ChainEntry, error::Error as BeamError}; + +const ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +#[test] +fn parses_literal_address_and_checksums_output() { + let target = build_target_from_entry(chain(), "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") + .expect("target"); + + assert_eq!( + target.checksum_address, + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + ); + assert_eq!( + target.input_address, + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + ); +} + +#[test] +fn rejects_non_literal_contract_addresses() { + let err = build_target_from_entry(chain(), "1111111111111111111111111111111111111111") + .expect_err("missing 0x"); + assert!(matches!(err, Error::InvalidContractAddress { .. })); + + let err = build_target_from_entry(chain(), "alice.eth").expect_err("ens is not accepted"); + assert!(matches!(err, Error::InvalidContractAddress { .. })); +} + +#[test] +fn source_path_matching_uses_exact_then_unique_basename() { + let sources = source_map(&[ + ("contracts/Foo.sol", "contract Foo {}"), + ("Bar.sol", "contract Bar {}"), + ]); + + let exact = match_source_path(&sources, "contracts/Foo.sol").expect("exact match"); + assert_eq!(exact.kind, SourceMatchKind::Exact); + assert_eq!(exact.content, "contract Foo {}"); + + let basename = match_source_path(&sources, "Bar.sol").expect("basename match"); + assert_eq!(basename.kind, SourceMatchKind::Exact); + + let basename = match_source_path(&sources, "Foo.sol").expect("unique basename match"); + assert_eq!(basename.kind, SourceMatchKind::Basename); +} + +#[test] +fn source_path_matching_rejects_ambiguous_basenames() { + let sources = source_map(&[ + ("contracts/Foo.sol", "contract Foo {}"), + ("lib/Foo.sol", "contract Foo2 {}"), + ]); + + let err = match_source_path(&sources, "Foo.sol").expect_err("ambiguous basename"); + assert!(matches!(err, Error::SourcePathAmbiguous { .. })); +} + +#[test] +fn info_renders_failed_proxy_lookup_distinctly() { + let target = build_target_from_entry(chain(), ADDRESS).expect("target"); + let response = contract_response(); + let bytecode = BytecodeInfo { + byte_len: 1, + code_hash: "0x00".to_owned(), + hex: "0x01".to_owned(), + }; + let proxy = ProxyInfo { + implementations: Vec::new(), + proxy_type: None, + status: ProxyStatus::Failed, + }; + + let output = verified_output(&target, &bytecode, &response.contract, &proxy); + + assert!(output.default.contains("Proxy: lookup failed")); + assert!(!output.default.contains("Proxy: no")); +} + +#[test] +fn info_runtime_not_verified_preserves_valid_proxy_data() { + let target = build_target_from_entry(chain(), ADDRESS).expect("target"); + let mut response = contract_response(); + response.contract.runtime_match = None; + let bytecode = BytecodeInfo { + byte_len: 1, + code_hash: "0x00".to_owned(), + hex: "0x01".to_owned(), + }; + let proxy = ProxyInfo { + implementations: vec![ProxyImplementation { + address: "0x2222222222222222222222222222222222222222".to_owned(), + name: Some("Implementation".to_owned()), + }], + proxy_type: Some("EIP1967Proxy".to_owned()), + status: ProxyStatus::Resolved, + }; + + let output = sourcify_status_output(&target, &bytecode, "runtime_not_verified", None, &proxy); + + assert_eq!(output.value["proxy"]["status"], json!("resolved")); + assert_eq!( + output.value["proxy"]["implementations"][0]["address"], + json!("0x2222222222222222222222222222222222222222") + ); +} + +#[test] +fn info_not_verified_reports_requested_proxy_lookup_failed() { + let target = build_target_from_entry(chain(), ADDRESS).expect("target"); + let bytecode = BytecodeInfo { + byte_len: 1, + code_hash: "0x00".to_owned(), + hex: "0x01".to_owned(), + }; + + let output = sourcify_status_output(&target, &bytecode, "not_verified", None, &failed_proxy()); + + assert_eq!(output.value["proxy"]["status"], json!("failed")); +} + +#[test] +fn human_summaries_sanitize_sourcify_control_characters() { + let target = build_target_from_entry(chain(), ADDRESS).expect("target"); + let mut response = contract_response(); + response.contract.compilation = Some(CompilationSummary { + compiler: Some("solc\nspoof".to_owned()), + language: Some("Solidity\tInjected".to_owned()), + contract_name: Some("Foo\rBar".to_owned()), + }); + response.contract.verified_at = Some("2024-01-01\nBad".to_owned()); + let bytecode = BytecodeInfo { + byte_len: 1, + code_hash: "0x00".to_owned(), + hex: "0x01".to_owned(), + }; + let proxy = ProxyInfo { + implementations: vec![ProxyImplementation { + address: "0x2222222222222222222222222222222222222222".to_owned(), + name: Some("Impl\nName".to_owned()), + }], + proxy_type: Some("Proxy\tType".to_owned()), + status: ProxyStatus::Resolved, + }; + + let info_output = verified_output(&target, &bytecode, &response.contract, &proxy); + assert!(info_output.default.contains("Contract: Foo Bar")); + assert!(info_output.default.contains("Language: Solidity Injected")); + assert!(info_output.default.contains("Compiler: solc spoof")); + assert!(info_output.default.contains("Verified at: 2024-01-01 Bad")); + assert!(info_output.default.contains("Proxy type: Proxy Type")); + assert!( + info_output + .default + .contains("Implementation name: Impl Name") + ); + assert!(!info_output.default.contains("Foo\rBar")); + assert!(!info_output.default.contains("Impl\nName")); + + let source_output = source_summary_output( + &target, + &response.contract, + vec!["contracts/Foo\nBar.sol".to_owned()], + &proxy, + ); + assert!(source_output.default.contains("contracts/Foo Bar.sol")); + assert_eq!( + source_output.value["files"][0], + json!("contracts/Foo\nBar.sol") + ); +} + +#[test] +fn sourcify_miss_errors_carry_unchecked_rpc_context() { + let err: BeamError = Error::SourcifyNotVerified { + address: ADDRESS.to_owned(), + artifact: "ABI".to_owned(), + runtime_unchecked: Some(RuntimeUnchecked { + reason: Some("connection refused".to_owned()), + }), + } + .into(); + let message = err.to_string(); + + assert!(message.contains("runtime code was not checked")); + assert!(message.contains("connection refused")); +} + +#[test] +fn sourcify_miss_errors_carry_no_rpc_unchecked_context() { + let err: BeamError = Error::SourcifyNotVerified { + address: ADDRESS.to_owned(), + artifact: "Source".to_owned(), + runtime_unchecked: Some(RuntimeUnchecked { reason: None }), + } + .into(); + let message = err.to_string(); + + assert!(message.contains("runtime code was not checked")); + assert!(!message.contains("RPC check failed")); +} + +fn chain() -> ChainEntry { + ChainEntry { + aliases: Vec::new(), + chain_id: 1, + display_name: "Ethereum".to_owned(), + is_builtin: true, + key: "ethereum".to_owned(), + native_symbol: "ETH".to_owned(), + privacy: None, + } +} + +fn contract_response() -> ContractResponse { + ContractResponse { + endpoint: + "https://sourcify.dev/server/v2/contract/1/0x1111111111111111111111111111111111111111" + .to_owned(), + requested_fields: vec!["sources".to_owned()], + contract: ContractRecord { + chain_id: "1".to_owned(), + address: ADDRESS.to_owned(), + match_state: MatchState::ExactMatch, + creation_match: Some(MatchState::Match), + runtime_match: Some(MatchState::ExactMatch), + verified_at: Some("2024-08-08T13:20:07Z".to_owned()), + abi: Some(Vec::new()), + sources: Some(source_map(&[("contracts/Foo.sol", "contract Foo {}\n")])), + metadata: Some(json!({"compiler": "solc"})), + standard_json_input: Some(json!({"language": "Solidity"})), + compilation: None, + proxy_resolution: None, + }, + } +} + +fn source_map(items: &[(&str, &str)]) -> std::collections::BTreeMap { + items + .iter() + .map(|(path, content)| { + ( + (*path).to_owned(), + SourceFile { + content: (*content).to_owned(), + }, + ) + }) + .collect() +} diff --git a/pkg/beam-cli/src/commands/contract/tests/export.rs b/pkg/beam-cli/src/commands/contract/tests/export.rs new file mode 100644 index 0000000..40a9d52 --- /dev/null +++ b/pkg/beam-cli/src/commands/contract/tests/export.rs @@ -0,0 +1,276 @@ +// lint-long-file-override allow-max-lines=300 +#[cfg(unix)] +use std::os::unix::fs::symlink; +use std::{fs, path::Path}; + +use serde_json::json; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; + +use crate::commands::contract::{ + error::Error, + export::{ + commit_into_existing_for_test, export_bundle, flatten_source_key, + flatten_source_keys_for_test, + }, + target::build_target_from_entry, +}; + +use super::{ADDRESS, chain, contract_response, source_map}; + +#[test] +fn export_source_key_flattening_uses_portable_labels() { + let usdc_source_key = "/Users/aloysius.chan/repo/contracts/v2/FiatTokenV2_2.sol"; + assert_eq!( + flatten_source_key(usdc_source_key), + format!( + "Users_aloysius.chan_repo_contracts_v2_FiatTokenV2_2--{}.sol", + source_key_hash16(usdc_source_key) + ) + ); + + let traversal_key = "contracts/../Foo.sol"; + assert_eq!( + flatten_source_key(traversal_key), + format!("contracts_.._Foo--{}.sol", source_key_hash16(traversal_key)) + ); + + let unusual_key = "dir\\sub/Únicode\u{0007}.sol"; + let unusual_name = flatten_source_key(unusual_key); + assert!(unusual_name.is_ascii()); + assert!(!unusual_name.contains('/')); + assert!(!unusual_name.contains('\\')); + assert!(!unusual_name.contains('\u{0007}')); + assert!(unusual_name.ends_with(&format!("--{}.sol", source_key_hash16(unusual_key)))); + + let empty_key = "\n/💥"; + assert_eq!( + flatten_source_key(empty_key), + format!("source--{}", source_key_hash16(empty_key)) + ); + + let long_key = format!("{}.sol", "a".repeat(500)); + let long_name = flatten_source_key(&long_key); + assert!(long_name.len() <= 240); + assert!(long_name.ends_with(&format!("--{}.sol", source_key_hash16(&long_key)))); + + let long_extension_key = format!("stem.{}", "b".repeat(500)); + let long_extension_name = flatten_source_key(&long_extension_key); + assert!(long_extension_name.len() <= 240); + assert_eq!( + long_extension_name, + format!("stem--{}", source_key_hash16(&long_extension_key)) + ); +} + +#[test] +fn export_bundle_writes_manifest_and_artifacts() { + let temp_dir = TempDir::new().expect("temp dir"); + let destination = temp_dir.path().join("bundle"); + let target = build_target_from_entry(chain(), ADDRESS).expect("target"); + let response = contract_response(); + + let result = export_bundle(&target, &response, destination.to_str().expect("utf8 path")) + .expect("export bundle"); + + assert!(result.written_files.contains(&"abi.json".to_owned())); + assert!(result.written_files.contains(&"sourcify.json".to_owned())); + let source_key = "contracts/Foo.sol"; + let source_path = flattened_source_path(source_key); + assert!(result.written_files.contains(&source_path)); + assert_eq!( + fs::read_to_string(destination.join(&source_path)).expect("source file"), + "contract Foo {}\n" + ); + + let manifest = fs::read_to_string(destination.join("sourcify.json")).expect("manifest"); + let manifest: serde_json::Value = serde_json::from_str(&manifest).expect("manifest json"); + assert_eq!(manifest["chain_id"], json!(1)); + assert_eq!( + manifest["files"]["abi.json"] + .as_str() + .expect("abi hash") + .len(), + 64 + ); + let source_hash = sha256_hex("contract Foo {}\n".as_bytes()); + assert_eq!(manifest["files"][source_path.as_str()], json!(source_hash)); + assert_eq!( + manifest["source_files"][source_key]["path"], + json!(source_path) + ); + assert_eq!( + manifest["source_files"][source_key]["sha256"], + json!(source_hash) + ); + assert!(manifest["files"].get("sourcify.json").is_none()); +} + +#[test] +fn export_bundle_rejects_non_empty_destination() { + let temp_dir = TempDir::new().expect("temp dir"); + let destination = temp_dir.path().join("bundle"); + fs::create_dir(&destination).expect("destination dir"); + fs::write(destination.join("existing"), b"keep").expect("existing file"); + let target = build_target_from_entry(chain(), ADDRESS).expect("target"); + let response = contract_response(); + + let err = export_bundle(&target, &response, destination.to_str().expect("utf8 path")) + .expect_err("non-empty destination"); + + assert!(matches!(err, Error::ExportDestinationNotEmpty { .. })); + assert_eq!( + fs::read(destination.join("existing")).expect("existing file"), + b"keep" + ); +} + +#[cfg(unix)] +#[test] +fn export_bundle_rejects_symlink_destination() { + let temp_dir = TempDir::new().expect("temp dir"); + let real_destination = temp_dir.path().join("real"); + let destination = temp_dir.path().join("bundle-link"); + fs::create_dir(&real_destination).expect("real destination"); + symlink(&real_destination, &destination).expect("destination symlink"); + let target = build_target_from_entry(chain(), ADDRESS).expect("target"); + let response = contract_response(); + + let err = export_bundle(&target, &response, destination.to_str().expect("utf8 path")) + .expect_err("symlink destination"); + + assert!(matches!(err, Error::ExportDestinationInvalid { .. })); +} + +#[test] +fn export_bundle_accepts_absolute_and_traversal_looking_source_keys() { + let temp_dir = TempDir::new().expect("temp dir"); + let destination = temp_dir.path().join("bundle"); + let target = build_target_from_entry(chain(), ADDRESS).expect("target"); + let source_items = [ + ( + "/Users/aloysius.chan/repo/contracts/v2/FiatTokenV2_2.sol", + "contract FiatTokenV2_2 {}", + ), + ("contracts/../Foo.sol", "contract Foo {}"), + ]; + let response = contract_response_with_sources(&source_items); + + let result = export_bundle(&target, &response, destination.to_str().expect("utf8 path")) + .expect("export bundle"); + + for (source_key, content) in source_items { + let source_path = flattened_source_path(source_key); + assert!(result.written_files.contains(&source_path)); + assert_eq!(Path::new(&source_path).parent(), Some(Path::new("sources"))); + assert_eq!( + fs::read_to_string(destination.join(&source_path)).expect("source file"), + content + ); + } +} + +#[test] +fn export_source_key_flattening_rejects_duplicate_output_names() { + let source_keys = ["same/Name.sol".to_owned(), "same\\Name.sol".to_owned()]; + + let err = flatten_source_keys_for_test(source_keys.iter(), "0000000000000000") + .expect_err("duplicate path"); + + assert!(matches!( + err, + Error::ExportPathCollision { path } if path == "same_Name--0000000000000000.sol" + )); +} + +#[test] +fn export_commit_does_not_overwrite_files_that_appear_during_commit() { + let temp_dir = TempDir::new().expect("temp dir"); + let prepared = temp_dir.path().join("prepared"); + let destination = temp_dir.path().join("destination"); + fs::create_dir(&prepared).expect("prepared dir"); + fs::create_dir(&destination).expect("destination dir"); + fs::write(prepared.join("a"), b"a").expect("prepared a"); + fs::write(prepared.join("b"), b"b").expect("prepared b"); + let mut injected_conflict = false; + let err = { + let mut after_move = |moved: &Path| { + if moved.file_name().and_then(|name| name.to_str()) == Some("a") { + fs::write(destination.join("b"), b"conflict").expect("conflict file"); + injected_conflict = true; + } + }; + commit_into_existing_for_test( + &prepared, + &destination, + destination.to_str().expect("utf8 path"), + None, + Some(&mut after_move), + ) + .expect_err("commit failure") + }; + + assert!(matches!(err, Error::ExportDestinationNotEmpty { .. })); + assert!(injected_conflict); + assert!(!destination.join("a").exists()); + assert_eq!( + fs::read(destination.join("b")).expect("conflict file"), + b"conflict" + ); + assert_eq!(fs::read(prepared.join("b")).expect("prepared b"), b"b"); +} + +#[test] +fn export_commit_cleans_moved_files_after_write_failure() { + let temp_dir = TempDir::new().expect("temp dir"); + let prepared = temp_dir.path().join("prepared"); + let destination = temp_dir.path().join("destination"); + fs::create_dir(&prepared).expect("prepared dir"); + fs::create_dir(&destination).expect("destination dir"); + fs::write(prepared.join("a"), b"a").expect("prepared a"); + fs::create_dir(prepared.join("b")).expect("prepared b"); + let mut injected_conflict = false; + let err = { + let mut before_move = |target: &Path| { + if target.file_name().and_then(|name| name.to_str()) == Some("b") { + fs::write(target, b"conflict").expect("conflict file"); + injected_conflict = true; + } + }; + commit_into_existing_for_test( + &prepared, + &destination, + destination.to_str().expect("utf8 path"), + Some(&mut before_move), + None, + ) + .expect_err("commit failure") + }; + + assert!(matches!(err, Error::ExportWriteFailed { .. })); + assert!(injected_conflict); + assert!(!destination.join("a").exists()); + assert_eq!( + fs::read(destination.join("b")).expect("conflict file"), + b"conflict" + ); + assert!(prepared.join("b").is_dir()); +} + +fn contract_response_with_sources(items: &[(&str, &str)]) -> sourcify_interface::ContractResponse { + let mut response = contract_response(); + response.contract.sources = Some(source_map(items)); + response +} + +fn flattened_source_path(source_key: &str) -> String { + format!("sources/{}", flatten_source_key(source_key)) +} + +fn source_key_hash16(source_key: &str) -> String { + sha256_hex(source_key.as_bytes())[..16].to_owned() +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} diff --git a/pkg/beam-cli/src/commands/gas.rs b/pkg/beam-cli/src/commands/gas.rs new file mode 100644 index 0000000..b6d881b --- /dev/null +++ b/pkg/beam-cli/src/commands/gas.rs @@ -0,0 +1,290 @@ +// lint-long-file-override allow-max-lines=300 +use serde_json::{Value, json}; +use web3::ethabi::StateMutability; + +use crate::{ + abi::parse_function, + cli::{Erc20GasAction, GasAction, SendArgs, TransferArgs}, + commands::call::{parse_transaction_value, resolve_address_args}, + error::Result, + evm::{ + FunctionCall, TransactionGas, erc20_decimals, estimate_function_gas, estimate_native_gas, + format_units, parse_units, + }, + human_output::sanitize_control_chars, + output::{CommandOutput, with_loading}, + runtime::{BeamApp, parse_address}, +}; + +pub async fn run(app: &BeamApp, action: GasAction) -> Result<()> { + match action { + GasAction::Transfer(args) => estimate_transfer(app, args).await, + GasAction::Erc20 { action } => estimate_erc20(app, action).await, + GasAction::Send(args) => estimate_send(app, args).await, + } +} + +async fn estimate_transfer(app: &BeamApp, args: TransferArgs) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let from = app.active_address().await?; + let to = app.resolve_wallet_or_address(&args.to).await?; + let amount = parse_units(&args.amount, 18)?; + let gas = with_loading( + app.output_mode, + format!("Estimating gas for transfer to {to:#x}..."), + async { estimate_native_gas(&client, from, to, amount).await }, + ) + .await?; + + render_gas_output(GasOutputConfig { + chain_key: &chain.entry.key, + default_summary: format!( + "Estimated gas for transfer of {} {} to {to:#x}", + args.amount, chain.entry.native_symbol + ), + extra: json!({ + "amount": args.amount, + "from": format!("{from:#x}"), + "kind": "transfer", + "to": format!("{to:#x}"), + }), + gas, + native_symbol: &chain.entry.native_symbol, + }) + .print(app.output_mode) +} + +async fn estimate_erc20(app: &BeamApp, action: Erc20GasAction) -> Result<()> { + match action { + Erc20GasAction::Transfer { token, to, amount } => { + estimate_erc20_write(app, token, to, amount, Erc20GasKind::Transfer).await + } + Erc20GasAction::Approve { + token, + spender, + amount, + } => estimate_erc20_write(app, token, spender, amount, Erc20GasKind::Approve).await, + } +} + +async fn estimate_erc20_write( + app: &BeamApp, + token: String, + target: String, + amount: String, + kind: Erc20GasKind, +) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let from = app.active_address().await?; + let token = app.token_for_chain(&token, &chain.entry.key).await?; + let token_label = sanitize_control_chars(&token.label); + let target = app.resolve_wallet_or_address(&target).await?; + let decimals = match token.decimals { + Some(decimals) => decimals, + None => { + with_loading( + app.output_mode, + format!("Fetching {token_label} token metadata..."), + async { erc20_decimals(&client, token.address).await }, + ) + .await? + } + }; + let amount_value = parse_units(&amount, usize::from(decimals))?; + let function = parse_function(kind.signature(), StateMutability::NonPayable)?; + let function_args = vec![format!("{target:#x}"), amount_value.to_string()]; + let gas = with_loading( + app.output_mode, + format!( + "Estimating gas for {} of {amount} {token_label}...", + kind.noun() + ), + async { + estimate_function_gas( + &client, + from, + FunctionCall { + args: &function_args, + contract: token.address, + function: &function, + value: 0u8.into(), + }, + ) + .await + }, + ) + .await?; + let mut extra = json!({ + "amount": amount, + "from": format!("{from:#x}"), + "kind": kind.json_kind(), + "token": token.label, + "token_address": format!("{:#x}", token.address), + }); + if let Some(extra) = extra.as_object_mut() { + extra.insert( + kind.target_key().to_string(), + Value::String(format!("{target:#x}")), + ); + } + + render_gas_output(GasOutputConfig { + chain_key: &chain.entry.key, + default_summary: format!( + "Estimated gas for {} of {amount} {token_label} {} {target:#x}", + kind.noun(), + kind.preposition() + ), + extra, + gas, + native_symbol: &chain.entry.native_symbol, + }) + .print(app.output_mode) +} + +async fn estimate_send(app: &BeamApp, args: SendArgs) -> Result<()> { + let (chain, client) = app.active_chain_client().await?; + let from = app.active_address().await?; + let value_display = args.value.clone().unwrap_or_else(|| "0".to_string()); + let value = parse_transaction_value(args.value.as_deref())?; + let contract = parse_address(&args.call.contract)?; + let function = parse_function(&args.call.function_sig, StateMutability::NonPayable)?; + let call_args = resolve_address_args(app, &function, &args.call.args).await?; + let gas = with_loading( + app.output_mode, + format!("Estimating gas for transaction to {contract:#x}..."), + async { + estimate_function_gas( + &client, + from, + FunctionCall { + args: &call_args, + contract, + function: &function, + value, + }, + ) + .await + }, + ) + .await?; + + render_gas_output(GasOutputConfig { + chain_key: &chain.entry.key, + default_summary: if value.is_zero() { + format!("Estimated gas for transaction to {contract:#x}") + } else { + format!( + "Estimated gas for transaction to {contract:#x} with {value_display} {}", + chain.entry.native_symbol + ) + }, + extra: json!({ + "contract": format!("{contract:#x}"), + "from": format!("{from:#x}"), + "kind": "send", + "signature": args.call.function_sig, + "value": value_display, + }), + gas, + native_symbol: &chain.entry.native_symbol, + }) + .print(app.output_mode) +} + +struct GasOutputConfig<'a> { + chain_key: &'a str, + default_summary: String, + extra: serde_json::Value, + gas: TransactionGas, + native_symbol: &'a str, +} + +#[derive(Clone, Copy)] +enum Erc20GasKind { + Approve, + Transfer, +} + +#[cfg(test)] +#[path = "gas/tests.rs"] +mod tests; + +impl Erc20GasKind { + fn json_kind(self) -> &'static str { + match self { + Self::Approve => "erc20_approve", + Self::Transfer => "erc20_transfer", + } + } + + fn noun(self) -> &'static str { + match self { + Self::Approve => "approval", + Self::Transfer => "transfer", + } + } + + fn preposition(self) -> &'static str { + match self { + Self::Approve => "for", + Self::Transfer => "to", + } + } + + fn signature(self) -> &'static str { + match self { + Self::Approve => "approve(address,uint256)", + Self::Transfer => "transfer(address,uint256)", + } + } + + fn target_key(self) -> &'static str { + match self { + Self::Approve => "spender", + Self::Transfer => "to", + } + } +} + +fn render_gas_output(config: GasOutputConfig<'_>) -> CommandOutput { + let fee = config.gas.fee(); + let fee_display = format_units(fee, 18); + let mut value = json!({ + "chain": config.chain_key, + "estimated_fee": fee_display, + "estimated_fee_wei": fee.to_string(), + "gas_limit": config.gas.gas_limit.to_string(), + "gas_price": config.gas.gas_price.to_string(), + "native_symbol": config.native_symbol, + }); + + if let Some(output) = value.as_object_mut() + && let Some(extra) = config.extra.as_object() + { + output.extend(extra.clone()); + } + + CommandOutput::new( + format!( + "{}\nEstimated fee: {} {} ({} wei)\nGas limit: {}\nGas price: {} wei", + config.default_summary, + fee_display, + config.native_symbol, + fee, + config.gas.gas_limit, + config.gas.gas_price, + ), + value, + ) + .compact(fee_display.clone()) + .markdown(format!( + "- Chain: `{}`\n- Estimated fee: `{}` `{}` (`{}` wei)\n- Gas limit: `{}`\n- Gas price: `{}` wei", + config.chain_key, + fee_display, + config.native_symbol, + fee, + config.gas.gas_limit, + config.gas.gas_price, + )) +} diff --git a/pkg/beam-cli/src/commands/gas/tests.rs b/pkg/beam-cli/src/commands/gas/tests.rs new file mode 100644 index 0000000..a8d4f97 --- /dev/null +++ b/pkg/beam-cli/src/commands/gas/tests.rs @@ -0,0 +1,26 @@ +use contracts::U256; +use serde_json::json; + +use super::{GasOutputConfig, render_gas_output}; +use crate::evm::TransactionGas; + +#[test] +fn render_gas_output_includes_fee_details() { + let output = render_gas_output(GasOutputConfig { + chain_key: "base", + default_summary: "Estimated gas".to_string(), + extra: json!({ "kind": "transfer" }), + gas: TransactionGas { + gas_limit: U256::from(21_000u64), + gas_price: U256::from(1_000_000_000u64), + }, + native_symbol: "ETH", + }); + + assert!(output.default.contains("Estimated fee: 0.000021 ETH")); + assert_eq!(output.value["estimated_fee"], "0.000021"); + assert_eq!(output.value["estimated_fee_wei"], "21000000000000"); + assert_eq!(output.value["gas_limit"], "21000"); + assert_eq!(output.value["gas_price"], "1000000000"); + assert_eq!(output.value["kind"], "transfer"); +} diff --git a/pkg/beam-cli/src/commands/interactive.rs b/pkg/beam-cli/src/commands/interactive.rs index 2a489e3..869eaf2 100644 --- a/pkg/beam-cli/src/commands/interactive.rs +++ b/pkg/beam-cli/src/commands/interactive.rs @@ -7,15 +7,14 @@ use serde_json::json; pub(crate) use super::interactive_history::should_persist_history; #[cfg(test)] -pub(crate) use super::interactive_history::uses_matching_prefix_history_search; -#[cfg(test)] pub(crate) use super::interactive_parse::repl_command_args; pub(crate) use super::interactive_parse::{ ParsedLine, is_exit_command, merge_overrides, normalized_repl_command, parse_line, repl_err, }; use super::{ interactive_helper::{BeamHelper, help_text}, - interactive_history::{ReplHistory, bind_matching_prefix_history_search, sanitize_history}, + interactive_history::{ReplHistory, sanitize_history}, + interactive_history_navigation::bind_matching_prefix_history_search, interactive_interrupt::run_with_interrupt_owner, interactive_parse::{resolved_color_mode, resolved_output_mode}, interactive_state::{capture_repl_state, reconcile_repl_state, repl_state_mutation}, @@ -35,13 +34,15 @@ pub async fn run(app: &BeamApp) -> Result<()> { let mut editor = Editor::::with_history(config, ReplHistory::new()) .context("create beam repl editor")?; editor.set_helper(Some(BeamHelper::new())); - bind_matching_prefix_history_search(&mut editor); + let history_navigation = bind_matching_prefix_history_search(&mut editor); + history_navigation.attach_to_history(editor.history_mut()); load_sanitized_history(editor.history_mut(), &app.paths.history) .context("sanitize beam repl history")?; let mut overrides = app.overrides.clone(); canonicalize_startup_wallet_override(app, &mut overrides).await?; loop { + history_navigation.reset(); let session = session(app, &overrides); let prompt = prompt(&session).await?; if let Some(helper) = editor.helper_mut() { diff --git a/pkg/beam-cli/src/commands/interactive_history.rs b/pkg/beam-cli/src/commands/interactive_history.rs index 3766793..d2f73e2 100644 --- a/pkg/beam-cli/src/commands/interactive_history.rs +++ b/pkg/beam-cli/src/commands/interactive_history.rs @@ -1,4 +1,3 @@ -// lint-long-file-override allow-max-lines=300 #[path = "interactive_history_sensitive.rs"] mod sensitive; @@ -6,16 +5,17 @@ use std::path::Path; use clap::Parser; use rustyline::{ - Cmd, ConditionalEventHandler, Config, Editor, Event, EventContext, EventHandler, Helper, - KeyCode, KeyEvent, Modifiers, RepeatCount, + Config, history::{DefaultHistory, History, SearchDirection, SearchResult}, }; +use super::interactive_history_navigation::HistoryNavigationStateHandle; use crate::cli::{Cli, normalize_cli_args}; use sensitive::looks_like_sensitive_command; pub(crate) struct ReplHistory { inner: DefaultHistory, + navigation_state: Option, } impl ReplHistory { @@ -26,12 +26,17 @@ impl ReplHistory { pub(crate) fn with_config(config: &Config) -> Self { Self { inner: DefaultHistory::with_config(config), + navigation_state: None, } } pub(crate) fn iter(&self) -> impl DoubleEndedIterator + '_ { self.inner.iter() } + + pub(super) fn set_navigation_state(&mut self, state: HistoryNavigationStateHandle) { + self.navigation_state = Some(state); + } } impl Default for ReplHistory { @@ -46,7 +51,14 @@ impl History for ReplHistory { index: usize, dir: SearchDirection, ) -> rustyline::Result>> { - self.inner.get(index, dir) + let result = self.inner.get(index, dir)?; + if let (Some(state), Some(result)) = (&self.navigation_state, &result) { + let mut state = state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.set_cycled_entry(result.entry.as_ref()); + } + Ok(result) } fn add(&mut self, line: &str) -> rustyline::Result { @@ -108,12 +120,20 @@ impl History for ReplHistory { start: usize, dir: SearchDirection, ) -> rustyline::Result>> { - Ok(self.inner.starts_with(term, start, dir)?.map(|mut result| { - // Accepted prefix-history matches should behave like completed input: - // the cursor belongs at the end of the inserted command. - result.pos = result.entry.len(); - result - })) + if let Some(state) = &self.navigation_state { + let mut state = state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(prefix) = state.take_prefix_search_term(term) { + if let Some(mut result) = self.inner.starts_with(&prefix, start, dir)? { + result.pos = result.entry.len(); + return Ok(Some(result)); + } + return Ok(None); + } + } + + self.inner.starts_with(term, start, dir) } } @@ -137,48 +157,6 @@ pub(crate) fn sanitize_history(history: &mut ReplHistory) -> rustyline::Result(editor: &mut Editor) -where - H: Helper, - I: History, -{ - bind_history_search( - editor, - KeyEvent(KeyCode::Up, Modifiers::NONE), - SearchDirection::Reverse, - ); - bind_history_search( - editor, - KeyEvent(KeyCode::Down, Modifiers::NONE), - SearchDirection::Forward, - ); -} - -pub(crate) fn uses_matching_prefix_history_search(line: &str, pos: usize) -> bool { - line.get(..pos).is_some_and(|prefix| { - pos == line.len() && !line.contains('\n') && !prefix.trim().is_empty() - }) -} - -pub(crate) fn history_navigation_command( - line: &str, - pos: usize, - direction: SearchDirection, - repeat_count: RepeatCount, -) -> Cmd { - if uses_matching_prefix_history_search(line, pos) { - match direction { - SearchDirection::Reverse => Cmd::HistorySearchBackward, - SearchDirection::Forward => Cmd::HistorySearchForward, - } - } else { - match direction { - SearchDirection::Reverse => Cmd::LineUpOrPreviousHistory(repeat_count), - SearchDirection::Forward => Cmd::LineDownOrNextHistory(repeat_count), - } - } -} - pub(crate) fn should_persist_history(line: &str) -> bool { let line = line.trim(); if line.is_empty() { @@ -204,37 +182,3 @@ pub(crate) fn should_persist_history(line: &str) -> bool { .collect::>(); !looks_like_sensitive_command(&args) } - -fn bind_history_search(editor: &mut Editor, key: KeyEvent, direction: SearchDirection) -where - H: Helper, - I: History, -{ - editor.bind_sequence( - key, - EventHandler::Conditional(Box::new(PrefixHistorySearchHandler { direction })), - ); -} - -struct PrefixHistorySearchHandler { - direction: SearchDirection, -} - -impl ConditionalEventHandler for PrefixHistorySearchHandler { - fn handle( - &self, - event: &Event, - n: RepeatCount, - positive: bool, - ctx: &EventContext, - ) -> Option { - let _ = (event, n, positive); - - Some(history_navigation_command( - ctx.line(), - ctx.pos(), - self.direction, - n, - )) - } -} diff --git a/pkg/beam-cli/src/commands/interactive_history_navigation.rs b/pkg/beam-cli/src/commands/interactive_history_navigation.rs new file mode 100644 index 0000000..bfc27d7 --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive_history_navigation.rs @@ -0,0 +1,223 @@ +// lint-long-file-override allow-max-lines=300 +use std::sync::{Arc, Mutex}; + +use rustyline::{ + Cmd, ConditionalEventHandler, Editor, Event, EventContext, EventHandler, Helper, KeyCode, + KeyEvent, Modifiers, RepeatCount, + history::{History, SearchDirection}, +}; + +use super::interactive_history::ReplHistory; + +pub(super) type HistoryNavigationStateHandle = Arc>; + +pub(crate) struct HistoryNavigation { + state: HistoryNavigationStateHandle, +} + +impl HistoryNavigation { + pub(crate) fn attach_to_history(&self, history: &mut ReplHistory) { + history.set_navigation_state(Arc::clone(&self.state)); + } + + pub(crate) fn reset(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.mode = None; + } +} + +pub(crate) fn bind_matching_prefix_history_search( + editor: &mut Editor, +) -> HistoryNavigation +where + H: Helper, + I: History, +{ + let history_navigation = HistoryNavigation { + state: Arc::new(Mutex::new(HistoryNavigationState::default())), + }; + bind_history_search( + editor, + KeyEvent(KeyCode::Up, Modifiers::NONE), + SearchDirection::Reverse, + Arc::clone(&history_navigation.state), + ); + bind_history_search( + editor, + KeyEvent(KeyCode::Down, Modifiers::NONE), + SearchDirection::Forward, + Arc::clone(&history_navigation.state), + ); + history_navigation +} + +fn uses_matching_prefix_history_search(line: &str, pos: usize) -> bool { + line.get(..pos).is_some_and(|prefix| { + pos == line.len() && !line.contains('\n') && !prefix.trim().is_empty() + }) +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct HistoryNavigationState { + mode: Option, +} + +impl HistoryNavigationState { + fn command( + &mut self, + line: &str, + pos: usize, + direction: SearchDirection, + repeat_count: RepeatCount, + ) -> Cmd { + if line.contains('\n') { + self.mode = Some(HistoryNavigationMode::Cycling { entry: None }); + return line_history_command(direction, repeat_count); + } + + match &mut self.mode { + Some(HistoryNavigationMode::Cycling { entry }) if entry.as_deref() == Some(line) => { + return cycling_history_command(direction); + } + Some(HistoryNavigationMode::Prefix { + prefix, + pending_term, + }) if line + .get(..pos) + .is_some_and(|term| term.starts_with(prefix.as_str())) => + { + *pending_term = line.get(..pos).map(str::to_string); + return prefix_history_command(direction); + } + _ => {} + } + + if uses_matching_prefix_history_search(line, pos) { + self.mode = Some(HistoryNavigationMode::Prefix { + prefix: line[..pos].to_string(), + pending_term: Some(line[..pos].to_string()), + }); + prefix_history_command(direction) + } else { + self.mode = Some(HistoryNavigationMode::Cycling { entry: None }); + line_history_command(direction, repeat_count) + } + } + + pub(super) fn set_cycled_entry(&mut self, entry: &str) { + if matches!(self.mode, Some(HistoryNavigationMode::Cycling { .. })) { + self.mode = Some(HistoryNavigationMode::Cycling { + entry: Some(entry.to_string()), + }); + } + } + + pub(super) fn take_prefix_search_term(&mut self, term: &str) -> Option { + match &mut self.mode { + Some(HistoryNavigationMode::Prefix { + prefix, + pending_term, + .. + }) => { + let expected = pending_term.take(); + expected + .as_deref() + .is_some_and(|expected| expected == term) + .then(|| prefix.clone()) + } + _ => None, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum HistoryNavigationMode { + Cycling { + entry: Option, + }, + Prefix { + prefix: String, + pending_term: Option, + }, +} + +fn history_navigation_command( + state: &mut HistoryNavigationState, + line: &str, + pos: usize, + direction: SearchDirection, + repeat_count: RepeatCount, +) -> Cmd { + state.command(line, pos, direction, repeat_count) +} + +#[cfg(test)] +#[path = "interactive_history_navigation_tests.rs"] +mod tests; + +fn prefix_history_command(direction: SearchDirection) -> Cmd { + match direction { + SearchDirection::Reverse => Cmd::HistorySearchBackward, + SearchDirection::Forward => Cmd::HistorySearchForward, + } +} + +fn cycling_history_command(direction: SearchDirection) -> Cmd { + match direction { + SearchDirection::Reverse => Cmd::PreviousHistory, + SearchDirection::Forward => Cmd::NextHistory, + } +} + +fn line_history_command(direction: SearchDirection, repeat_count: RepeatCount) -> Cmd { + match direction { + SearchDirection::Reverse => Cmd::LineUpOrPreviousHistory(repeat_count), + SearchDirection::Forward => Cmd::LineDownOrNextHistory(repeat_count), + } +} + +fn bind_history_search( + editor: &mut Editor, + key: KeyEvent, + direction: SearchDirection, + state: HistoryNavigationStateHandle, +) where + H: Helper, + I: History, +{ + editor.bind_sequence( + key, + EventHandler::Conditional(Box::new(PrefixHistorySearchHandler { direction, state })), + ); +} + +struct PrefixHistorySearchHandler { + direction: SearchDirection, + state: HistoryNavigationStateHandle, +} + +impl ConditionalEventHandler for PrefixHistorySearchHandler { + fn handle( + &self, + _event: &Event, + n: RepeatCount, + _positive: bool, + ctx: &EventContext, + ) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + Some(history_navigation_command( + &mut state, + ctx.line(), + ctx.pos(), + self.direction, + n, + )) + } +} diff --git a/pkg/beam-cli/src/commands/interactive_history_navigation_tests.rs b/pkg/beam-cli/src/commands/interactive_history_navigation_tests.rs new file mode 100644 index 0000000..72993ad --- /dev/null +++ b/pkg/beam-cli/src/commands/interactive_history_navigation_tests.rs @@ -0,0 +1,292 @@ +// lint-long-file-override allow-max-lines=300 +use super::*; +use crate::commands::interactive_history::ReplHistory; +use rustyline::history::History; +use std::sync::{Arc, Mutex}; + +fn nav(line: &str, pos: usize, direction: SearchDirection, repeat_count: RepeatCount) -> Cmd { + let mut state = HistoryNavigationState::default(); + history_navigation_command(&mut state, line, pos, direction, repeat_count) +} + +fn assert_nav( + state: &mut HistoryNavigationState, + line: &str, + pos: usize, + direction: SearchDirection, + expected: Cmd, +) { + assert_eq!( + history_navigation_command(state, line, pos, direction, 1), + expected + ); +} + +fn reverse(line: &str, pos: usize) -> Cmd { + nav(line, pos, SearchDirection::Reverse, 1) +} + +fn forward(line: &str, pos: usize) -> Cmd { + nav(line, pos, SearchDirection::Forward, 1) +} + +#[test] +fn prefix_history_navigation_only_runs_for_real_prefixes_at_line_end() { + assert!(uses_matching_prefix_history_search( + "transfer", + "transfer".len() + )); + assert!(uses_matching_prefix_history_search( + "transfer ", + "transfer ".len() + )); + + assert!(!uses_matching_prefix_history_search("", 0)); + assert!(!uses_matching_prefix_history_search(" ", 3)); + assert!(!uses_matching_prefix_history_search("transfer", 3)); + assert!(!uses_matching_prefix_history_search( + "transfer\n0xabc", + "transfer\n0xabc".len() + )); +} + +#[test] +fn up_and_down_fall_back_to_history_cycling_without_a_prefix() { + assert_eq!(reverse("", 0), Cmd::LineUpOrPreviousHistory(1)); + assert_eq!(forward("", 0), Cmd::LineDownOrNextHistory(1)); + assert_eq!( + nav("transfer", 3, SearchDirection::Reverse, 4), + Cmd::LineUpOrPreviousHistory(4) + ); +} + +#[test] +fn up_and_down_keep_prefix_history_search_when_typing_at_line_end() { + assert_eq!( + reverse("transfer", "transfer".len()), + Cmd::HistorySearchBackward + ); + assert_eq!( + forward("transfer", "transfer".len()), + Cmd::HistorySearchForward + ); +} + +#[test] +fn multiline_entries_keep_line_navigation_fallback_and_latch_history() { + let entry = "transfer alice.eth\n--amount 1"; + let mut state = HistoryNavigationState::default(); + + assert_nav(&mut state, "", 0, SearchDirection::Reverse, reverse("", 0)); + state.set_cycled_entry(entry); + assert_nav( + &mut state, + entry, + entry.len(), + SearchDirection::Reverse, + Cmd::LineUpOrPreviousHistory(1), + ); + assert_eq!( + history_navigation_command(&mut state, entry, entry.len(), SearchDirection::Forward, 3), + Cmd::LineDownOrNextHistory(3) + ); + + let mut state = HistoryNavigationState::default(); + assert_eq!( + history_navigation_command(&mut state, entry, entry.len(), SearchDirection::Reverse, 2), + Cmd::LineUpOrPreviousHistory(2) + ); + state.set_cycled_entry("balance"); + assert_nav( + &mut state, + "balance", + "balance".len(), + SearchDirection::Reverse, + Cmd::PreviousHistory, + ); +} + +#[test] +fn repeated_history_cycling_does_not_switch_to_prefix_search() { + let mut state = HistoryNavigationState::default(); + + assert_nav( + &mut state, + "", + 0, + SearchDirection::Reverse, + Cmd::LineUpOrPreviousHistory(1), + ); + state.set_cycled_entry("transfer alice.eth"); + assert_nav( + &mut state, + "transfer alice.eth", + "transfer alice.eth".len(), + SearchDirection::Reverse, + Cmd::PreviousHistory, + ); + assert_nav( + &mut state, + "transfer alice.eth", + "transfer alice.eth".len(), + SearchDirection::Forward, + Cmd::NextHistory, + ); +} + +#[test] +fn editing_after_history_cycling_reenables_prefix_search() { + let mut state = HistoryNavigationState::default(); + + assert_nav( + &mut state, + "", + 0, + SearchDirection::Reverse, + Cmd::LineUpOrPreviousHistory(1), + ); + state.set_cycled_entry("balance"); + assert_nav( + &mut state, + "t", + "t".len(), + SearchDirection::Reverse, + Cmd::HistorySearchBackward, + ); +} + +#[test] +fn repeated_prefix_history_search_keeps_the_original_prefix() { + let mut state = HistoryNavigationState::default(); + + assert_nav( + &mut state, + "trans", + "trans".len(), + SearchDirection::Reverse, + Cmd::HistorySearchBackward, + ); + assert_nav( + &mut state, + "transfer alice.eth", + "trans".len(), + SearchDirection::Reverse, + Cmd::HistorySearchBackward, + ); + assert_nav( + &mut state, + "transfer alice.eth", + "trans".len(), + SearchDirection::Forward, + Cmd::HistorySearchForward, + ); +} + +#[test] +fn prefix_history_search_keeps_cursor_at_end_while_reusing_original_prefix() { + let mut history = ReplHistory::new(); + history + .add("transfer bob.eth") + .expect("add first transfer history"); + history + .add("transfer alice.eth") + .expect("add second transfer history"); + + let state: HistoryNavigationStateHandle = + Arc::new(Mutex::new(HistoryNavigationState::default())); + history.set_navigation_state(Arc::clone(&state)); + + { + let mut state = state.lock().expect("lock navigation state"); + assert_nav( + &mut state, + "trans", + "trans".len(), + SearchDirection::Reverse, + Cmd::HistorySearchBackward, + ); + } + + let first = history + .starts_with("trans", history.len() - 1, SearchDirection::Reverse) + .expect("search reverse history") + .expect("find reverse history entry"); + assert_eq!(first.entry.as_ref(), "transfer alice.eth"); + assert_eq!(first.pos, first.entry.len()); + + assert!( + history + .starts_with( + "transfer alice.ethx", + history.len() - 1, + SearchDirection::Reverse, + ) + .expect("normal history hint search") + .is_none() + ); + + { + let mut state = state.lock().expect("lock navigation state"); + assert_nav( + &mut state, + first.entry.as_ref(), + first.entry.len(), + SearchDirection::Reverse, + Cmd::HistorySearchBackward, + ); + } + + let repeated = history + .starts_with( + first.entry.as_ref(), + first.idx - 1, + SearchDirection::Reverse, + ) + .expect("repeat reverse history search") + .expect("find previous matching history entry"); + assert_eq!(repeated.entry.as_ref(), "transfer bob.eth"); + assert_eq!(repeated.pos, repeated.entry.len()); +} + +#[test] +fn prefix_history_search_stops_when_cursor_moves_before_original_prefix() { + let mut state = HistoryNavigationState::default(); + + assert_nav( + &mut state, + "trans", + "trans".len(), + SearchDirection::Reverse, + Cmd::HistorySearchBackward, + ); + assert_eq!(state.take_prefix_search_term("balance"), None); + + assert_nav( + &mut state, + "trans", + "trans".len(), + SearchDirection::Reverse, + Cmd::HistorySearchBackward, + ); + assert_eq!(state.take_prefix_search_term("transfer bob.ethx"), None); + + assert_nav( + &mut state, + "trans", + "trans".len(), + SearchDirection::Reverse, + Cmd::HistorySearchBackward, + ); + assert_eq!( + state.take_prefix_search_term("trans"), + Some("trans".to_string()) + ); + + assert_nav( + &mut state, + "transfer alice.eth", + 2, + SearchDirection::Reverse, + Cmd::LineUpOrPreviousHistory(1), + ); +} diff --git a/pkg/beam-cli/src/commands/interactive_parse.rs b/pkg/beam-cli/src/commands/interactive_parse.rs index 1d24500..6e5e018 100644 --- a/pkg/beam-cli/src/commands/interactive_parse.rs +++ b/pkg/beam-cli/src/commands/interactive_parse.rs @@ -133,7 +133,7 @@ pub(crate) fn normalized_repl_command(command: Option<&str>) -> Option<&str> { let command = command?; matches!( command, - "wallets" | "chains" | "rpc" | "balance" | "tokens" | "privacy" | "help" + "wallets" | "chains" | "rpc" | "balance" | "tokens" | "help" ) .then_some(command) } @@ -163,21 +163,6 @@ fn is_cli_subcommand_invocation(command: &str, args: &[String]) -> bool { ) | ( "tokens", Some("list" | "add" | "remove" | "help" | "-h" | "--help") - ) | ( - "privacy", - Some( - "address" - | "balance" - | "mint" - | "burn" - | "send" - | "incoming" - | "claim" - | "state" - | "help" - | "-h" - | "--help" - ) ) ) } diff --git a/pkg/beam-cli/src/commands/mod.rs b/pkg/beam-cli/src/commands/mod.rs index 60116af..2ce7c91 100644 --- a/pkg/beam-cli/src/commands/mod.rs +++ b/pkg/beam-cli/src/commands/mod.rs @@ -1,12 +1,16 @@ +pub mod apps; pub mod balance; pub mod block; pub mod call; pub mod chain; +pub mod contract; pub mod erc20; pub mod fetch; +pub mod gas; pub mod interactive; pub(crate) mod interactive_helper; pub(crate) mod interactive_history; +pub(crate) mod interactive_history_navigation; pub(crate) mod interactive_interrupt; pub(crate) mod interactive_parse; pub(crate) mod interactive_state; @@ -31,12 +35,16 @@ pub async fn run(app: &BeamApp, command: Command) -> Result<()> { Command::Chain { action } => chain::run(app, action).await, Command::Rpc { action } => rpc::run(app, action).await, Command::Tokens { action } => tokens::run(app, action).await, + Command::Apps { action } => apps::run(app, action).await, + Command::X(args) => apps::run_app(app, args).await, Command::Privacy { action } => privacy::run(app, action).await, Command::Balance(args) => balance::run(app, args).await, Command::Transfer(args) => transfer::run(app, args).await, + Command::Gas { action } => gas::run(app, action).await, Command::Txn(args) => txn::run(app, args).await, Command::Block(args) => block::run(app, args).await, Command::Erc20 { action } => erc20::run(app, action).await, + Command::Contract { action } => contract::run(app, action).await, Command::Call(args) => call::run_read(app, args).await, Command::Send(args) => call::run_write(app, args).await, Command::Fetch(args) => fetch::run(app, args).await, diff --git a/pkg/beam-cli/src/error.rs b/pkg/beam-cli/src/error.rs index 0d35ece..ff20bc6 100644 --- a/pkg/beam-cli/src/error.rs +++ b/pkg/beam-cli/src/error.rs @@ -1,6 +1,8 @@ -// lint-long-file-override allow-max-lines=300 +// lint-long-file-override allow-max-lines=400 use contextful::{FromContextful, InternalError}; +use crate::apps::Error as AppError; + pub type Result = std::result::Result; #[derive(Debug, thiserror::Error, FromContextful)] @@ -123,12 +125,81 @@ pub enum Error { #[error("[beam-cli] invalid address: {value}")] InvalidAddress { value: String }, + #[error("[beam-cli/contract] invalid contract address: {value}")] + InvalidContractAddress { value: String }, + + #[error( + "[beam-cli/contract] rpc chain mismatch for {chain}: expected {expected}, got {actual}" + )] + ContractRpcChainMismatch { + actual: u64, + chain: String, + expected: u64, + }, + + #[error("[beam-cli/contract] rpc lookup failed: {reason}")] + ContractRpcLookupFailed { reason: String }, + + #[error("[beam-cli/contract] no runtime code at {address}")] + ContractNoRuntimeCode { address: String }, + + #[error( + "[beam-cli/contract] Sourcify artifact not found for {address}: {artifact}{runtime_check}" + )] + ContractSourcifyNotVerified { + address: String, + artifact: String, + runtime_check: String, + }, + + #[error( + "[beam-cli/contract] Sourcify runtime not verified for {address}: {artifact}{runtime_check}" + )] + ContractSourcifyRuntimeNotVerified { + address: String, + artifact: String, + runtime_check: String, + }, + + #[error("[beam-cli/contract] Sourcify does not support chain {chain_id}")] + ContractSourcifyChainUnsupported { chain_id: u64 }, + + #[error("[beam-cli/contract] Sourcify lookup failed for {address}: {reason}")] + ContractSourcifyLookupFailed { address: String, reason: String }, + + #[error("[beam-cli/contract] Sourcify response exceeded {cap_bytes} bytes")] + ContractSourcifyResponseTooLarge { cap_bytes: usize }, + + #[error("[beam-cli/contract] malformed Sourcify response: {reason}")] + ContractSourcifyMalformedResponse { reason: String }, + + #[error("[beam-cli/contract] source path not found: {path}")] + ContractSourcePathNotFound { path: String }, + + #[error("[beam-cli/contract] source path is ambiguous: {path}")] + ContractSourcePathAmbiguous { path: String }, + + #[error("[beam-cli/contract] export destination is invalid: {path}")] + ContractExportDestinationInvalid { path: String }, + + #[error("[beam-cli/contract] export destination is not empty: {path}")] + ContractExportDestinationNotEmpty { path: String }, + + #[error("[beam-cli/contract] export filename collision: {path}")] + ContractExportPathCollision { path: String }, + + #[error("[beam-cli/contract] export write failed: {reason}")] + ContractExportWriteFailed { reason: String }, + #[error("[beam-cli] invalid transaction hash: {value}")] InvalidTransactionHash { value: String }, #[error("[beam-cli] invalid block selector: {value}")] InvalidBlockSelector { value: String }, + #[error("[beam-cli] app error")] + App(#[source] AppError), + #[error("[beam-cli] invalid rpc url: {value}")] InvalidRpcUrl { value: String }, @@ -252,7 +323,7 @@ pub enum Error { #[error("[beam-cli] key derivation failed")] KeyDerivationFailed, - #[error("[beam-cli] password cannot be empty or whitespace only")] + #[error("[beam-cli] password cannot be whitespace only")] PasswordBlank, #[error("[beam-cli] password confirmation does not match")] @@ -296,3 +367,9 @@ pub enum Error { #[error("[beam-cli] internal error")] Internal(#[from] InternalError), } + +impl From for Error { + fn from(err: AppError) -> Self { + Self::App(err) + } +} diff --git a/pkg/beam-cli/src/evm.rs b/pkg/beam-cli/src/evm.rs index 25a63ab..b3469cd 100644 --- a/pkg/beam-cli/src/evm.rs +++ b/pkg/beam-cli/src/evm.rs @@ -1,4 +1,6 @@ -// lint-long-file-override allow-max-lines=300 +// lint-long-file-override allow-max-lines=400 +mod gas; + use contextful::ResultContextExt; use contracts::{Address, Client, ERC20Contract, U256}; use web3::{ @@ -6,6 +8,7 @@ use web3::{ types::{Bytes, CallRequest, TransactionParameters, TransactionReceipt}, }; +use self::gas::resolve_transaction_gas; pub use crate::units::{format_units, parse_units, validate_unit_decimals}; use crate::{ abi::{decode_output, encode_input, parse_function, tokens_to_json}, @@ -13,6 +16,7 @@ use crate::{ signer::Signer, transaction::{TransactionExecution, TransactionStatusUpdate, submit_and_wait}, }; +pub use gas::{TransactionGas, estimate_function_gas, estimate_native_gas}; #[derive(Clone, Debug)] pub struct CallOutcome { @@ -35,10 +39,12 @@ pub struct FunctionCall<'a> { pub value: U256, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct TransactionGas { - pub gas_limit: U256, - pub gas_price: U256, +#[derive(Clone, Debug)] +pub struct CalldataTransaction { + pub data: Vec, + pub to: Address, + pub value: U256, + pub gas: Option, } pub async fn native_balance(client: &Client, address: Address) -> Result { @@ -60,6 +66,39 @@ pub async fn erc20_balance(client: &Client, token: Address, owner: Address) -> R Ok(balance) } +pub async fn erc20_allowance( + client: &Client, + token: Address, + owner: Address, + spender: Address, +) -> Result { + let function = parse_function( + "allowance(address,address):(uint256)", + StateMutability::View, + )?; + let outcome = call_function( + client, + Some(owner), + token, + &function, + &[format!("{owner:#x}"), format!("{spender:#x}")], + ) + .await?; + let decoded = outcome + .decoded + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: "allowance(address,address):(uint256)".to_string(), + })?; + let value = decoded[0] + .as_str() + .ok_or_else(|| Error::InvalidFunctionSignature { + signature: "allowance(address,address):(uint256)".to_string(), + })? + .parse::() + .context("parse beam erc20 allowance")?; + Ok(value) +} + pub async fn erc20_decimals(client: &Client, token: Address) -> Result { let function = parse_function("decimals():(uint8)", StateMutability::View)?; let outcome = call_function(client, None, token, &function, &[]).await?; @@ -164,6 +203,49 @@ pub async fn send_function_with_gas( submit_transaction(client, signer, tx, on_status, cancel).await } +pub async fn send_calldata_with_gas( + client: &Client, + signer: &S, + transaction: CalldataTransaction, + on_status: impl FnMut(TransactionStatusUpdate), + cancel: impl std::future::Future, +) -> Result { + let tx = prepare_transaction( + client, + signer.address(), + transaction.to, + transaction.data, + transaction.value, + transaction.gas, + ) + .await?; + submit_transaction(client, signer, tx, on_status, cancel).await +} + +pub async fn simulate_calldata( + client: &Client, + from: Address, + to: Address, + data: Vec, + value: U256, +) -> Result<()> { + client + .eth_call( + CallRequest { + data: Some(Bytes(data)), + from: Some(from), + to: Some(to), + value: Some(value), + ..Default::default() + }, + None, + ) + .await + .context("simulate beam transaction")?; + + Ok(()) +} + async fn prepare_transaction( client: &Client, from: Address, @@ -203,63 +285,6 @@ async fn fill_transaction( }) } -async fn resolve_transaction_gas( - client: &Client, - from: Address, - to: Address, - data: &[u8], - value: U256, - gas: Option, -) -> Result { - match gas { - Some(gas) => Ok(gas), - None => estimate_transaction_gas(client, from, to, data, value).await, - } -} - -async fn estimate_transaction_gas( - client: &Client, - from: Address, - to: Address, - data: &[u8], - value: U256, -) -> Result { - let gas_limit = estimate_gas_limit(client, from, to, data, value).await?; - let gas_price = client - .fast_gas_price() - .await - .context("fetch beam gas price")?; - - Ok(TransactionGas { - gas_limit, - gas_price, - }) -} - -async fn estimate_gas_limit( - client: &Client, - from: Address, - to: Address, - data: &[u8], - value: U256, -) -> Result { - let gas = client - .estimate_gas( - CallRequest { - data: Some(Bytes(data.to_vec())), - from: Some(from), - to: Some(to), - value: Some(value), - ..Default::default() - }, - None, - ) - .await - .context("estimate beam transaction gas")?; - - Ok(gas + gas / 5) -} - async fn submit_transaction( client: &Client, signer: &S, diff --git a/pkg/beam-cli/src/evm/gas.rs b/pkg/beam-cli/src/evm/gas.rs new file mode 100644 index 0000000..200e3fe --- /dev/null +++ b/pkg/beam-cli/src/evm/gas.rs @@ -0,0 +1,93 @@ +use contextful::ResultContextExt; +use contracts::{Address, Client, U256}; +use web3::types::{Bytes, CallRequest}; + +use super::FunctionCall; +use crate::{abi::encode_input, error::Result}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TransactionGas { + pub gas_limit: U256, + pub gas_price: U256, +} + +impl TransactionGas { + pub fn fee(&self) -> U256 { + self.gas_limit * self.gas_price + } +} + +pub async fn estimate_native_gas( + client: &Client, + from: Address, + to: Address, + amount: U256, +) -> Result { + estimate_transaction_gas(client, from, to, &[], amount).await +} + +pub async fn estimate_function_gas( + client: &Client, + from: Address, + call: FunctionCall<'_>, +) -> Result { + let data = encode_input(call.function, call.args)?; + estimate_transaction_gas(client, from, call.contract, &data, call.value).await +} + +pub(super) async fn resolve_transaction_gas( + client: &Client, + from: Address, + to: Address, + data: &[u8], + value: U256, + gas: Option, +) -> Result { + match gas { + Some(gas) => Ok(gas), + None => estimate_transaction_gas(client, from, to, data, value).await, + } +} + +async fn estimate_transaction_gas( + client: &Client, + from: Address, + to: Address, + data: &[u8], + value: U256, +) -> Result { + let gas_limit = estimate_gas_limit(client, from, to, data, value).await?; + let gas_price = client + .fast_gas_price() + .await + .context("fetch beam gas price")?; + + Ok(TransactionGas { + gas_limit, + gas_price, + }) +} + +async fn estimate_gas_limit( + client: &Client, + from: Address, + to: Address, + data: &[u8], + value: U256, +) -> Result { + let gas = client + .estimate_gas( + CallRequest { + data: Some(Bytes(data.to_vec())), + from: Some(from), + to: Some(to), + value: Some(value), + ..Default::default() + }, + None, + ) + .await + .context("estimate beam transaction gas")?; + + Ok(gas + gas / 5) +} diff --git a/pkg/beam-cli/src/keystore.rs b/pkg/beam-cli/src/keystore.rs index ef52ef3..3744a3d 100644 --- a/pkg/beam-cli/src/keystore.rs +++ b/pkg/beam-cli/src/keystore.rs @@ -146,7 +146,14 @@ pub fn prompt_wallet_name(default_name: &str) -> Result { } pub fn prompt_new_password() -> Result { - let password = prompt_secret("beam password: ", "read beam password")?; + let password = prompt_secret( + "beam password (empty for no password): ", + "read beam password", + )?; + if password.is_empty() { + return Ok(password); + } + let confirmation = prompt_secret("confirm beam password: ", "read beam password confirmation")?; validate_new_password(&password, &confirmation).map(|_| password) } @@ -167,7 +174,10 @@ where } pub(crate) fn validate_new_password(password: &str, confirmation: &str) -> Result<()> { - match (password.trim().is_empty(), password == confirmation) { + match ( + !password.is_empty() && password.trim().is_empty(), + password == confirmation, + ) { (true, _) => Err(Error::PasswordBlank), (false, true) => Ok(()), (false, false) => Err(Error::PasswordConfirmationMismatch), diff --git a/pkg/beam-cli/src/main.rs b/pkg/beam-cli/src/main.rs index 6df4cef..611d5ac 100644 --- a/pkg/beam-cli/src/main.rs +++ b/pkg/beam-cli/src/main.rs @@ -1,4 +1,5 @@ mod abi; +mod apps; mod chains; mod cli; mod commands; diff --git a/pkg/beam-cli/src/runtime.rs b/pkg/beam-cli/src/runtime.rs index 1305a0b..3f445e3 100644 --- a/pkg/beam-cli/src/runtime.rs +++ b/pkg/beam-cli/src/runtime.rs @@ -1,4 +1,4 @@ -// lint-long-file-override allow-max-lines=300 +// lint-long-file-override allow-max-lines=340 mod wallet_selector; #[cfg(unix)] @@ -84,6 +84,14 @@ impl BeamApp { } pub async fn active_chain(&self) -> Result { + let entry = self.active_chain_entry().await?; + let config = self.config_store.get().await; + let rpc_url = active_rpc_url(&self.overrides, &config, &entry)?; + + Ok(ResolvedChain { entry, rpc_url }) + } + + pub async fn active_chain_entry(&self) -> Result { let config = self.config_store.get().await; let selection = self .overrides @@ -91,10 +99,13 @@ impl BeamApp { .clone() .unwrap_or_else(|| config.default_chain.clone()); let chains = self.chain_store.get().await; - let entry = find_chain(&selection, &chains)?; - let rpc_url = active_rpc_url(&self.overrides, &config, &entry)?; - Ok(ResolvedChain { entry, rpc_url }) + find_chain(&selection, &chains) + } + + pub async fn active_rpc_url_for_chain(&self, entry: &ChainEntry) -> Result { + let config = self.config_store.get().await; + active_rpc_url(&self.overrides, &config, entry) } pub async fn active_chain_client(&self) -> Result<(ResolvedChain, Client)> { diff --git a/pkg/beam-cli/src/tests.rs b/pkg/beam-cli/src/tests.rs index 59cc326..c1e3f55 100644 --- a/pkg/beam-cli/src/tests.rs +++ b/pkg/beam-cli/src/tests.rs @@ -1,9 +1,13 @@ mod abi; +mod apps; +mod apps_host; mod balance; mod call; mod chains; mod cli; +mod cli_contract; mod cli_fetch; +mod cli_gas; mod cli_metadata; mod cli_privacy; mod config; @@ -11,6 +15,7 @@ mod display; mod ens; mod erc20; mod evm; +mod evm_gas; mod evm_prepared_gas; mod evm_retries; mod fetch; diff --git a/pkg/beam-cli/src/tests/apps.rs b/pkg/beam-cli/src/tests/apps.rs new file mode 100644 index 0000000..4bb3f6d --- /dev/null +++ b/pkg/beam-cli/src/tests/apps.rs @@ -0,0 +1,236 @@ +// lint-long-file-override allow-max-lines=300 +use std::path::{Path, PathBuf}; + +use crate::{ + apps::{ + model::{ + AppCatalogMetadata, AppManifest, AppPermissions, ChainOperation, ChainPermission, + HostApi, HttpPermission, PrivacyCapability, RegistryIndex, RegistrySignature, + StoragePermission, WalletPermissions, WasmArtifact, + }, + privacy::reject_unsupported, + registry::{DEFAULT_REGISTRY_URL, ensure_digest, signing_digest}, + validate::{ensure_beam_version, validate_index, validate_manifest}, + }, + cli::{AppsAction, Cli, Command}, +}; +use clap::Parser; + +fn manifest() -> AppManifest { + AppManifest { + format_version: 1, + id: "sample".to_string(), + display_name: "Sample".to_string(), + version: "1.0.0".to_string(), + publisher: "Payy".to_string(), + description: "Sample app".to_string(), + min_beam_version: "0.0.1".to_string(), + wasm: WasmArtifact { + sha256: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + entrypoint: "beam_app_main".to_string(), + }, + icon: None, + catalog: AppCatalogMetadata::default(), + commands: vec![crate::apps::model::AppCommand { + name: "echo".to_string(), + about: "Echo input".to_string(), + usage: "echo ".to_string(), + sensitive_args: Vec::new(), + input_schema: serde_json::json!({ "type": "object" }), + output_schema: serde_json::json!({ "type": "object" }), + docs: None, + }], + permissions: AppPermissions { + http: vec![HttpPermission { + url: "https://api.example.com/*".to_string(), + }], + chains: vec![ChainPermission { + chain: "base".to_string(), + operations: vec![ChainOperation::Read], + contracts: Some(vec!["uniswap-*".to_string()]), + selectors: Some(vec!["0x12345678".to_string()]), + spenders: None, + }], + wallet: WalletPermissions { + read_balances: true, + propose_transactions: false, + erc20_approval: false, + }, + storage: StoragePermission { app_local: true }, + privacy: Vec::new(), + }, + host_api: HostApi { + privacy_reserved: true, + }, + signature: RegistrySignature { + algorithm: "sha256-dev".to_string(), + key_id: "test".to_string(), + value: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + }, + } +} + +#[test] +fn validates_manifest_with_optional_glob_scopes() { + validate_manifest(&manifest()).expect("validate manifest"); +} + +#[test] +fn parses_apps_lifecycle_commands() { + let cli = Cli::try_parse_from(["beam", "apps", "install", "sample", "--dry-run"]) + .expect("parse apps install"); + match cli.command { + Some(Command::Apps { + action: AppsAction::Install(args), + }) if args.app == "sample" && args.dry_run => {} + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parses_x_alias_with_trailing_args() { + let cli = Cli::try_parse_from([ + "beam", + "x", + "uniswap", + "swap", + "USDC", + "ETH", + "10", + "--prepare", + ]) + .expect("parse x alias"); + match cli.command { + Some(Command::X(args)) if args.app == "uniswap" && args.args.contains(&"swap".into()) => {} + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn privacy_capabilities_parse_and_fail_closed() { + let mut manifest = manifest(); + manifest.permissions.privacy = vec![PrivacyCapability::PrivateBalance]; + validate_manifest(&manifest).expect("validate privacy manifest"); + let error = reject_unsupported(PrivacyCapability::PrivateBalance).expect_err("unsupported"); + assert!( + error + .to_string() + .contains("unsupported privacy app capability") + ); +} + +#[test] +fn valid_registry_fixture_signatures_are_valid() { + let bundle = repo_root().join("beam-apps/fixtures/valid"); + let index = read_json::(&bundle.join("index.json")); + validate_index(&index, DEFAULT_REGISTRY_URL).expect("validate index"); + assert_eq!( + index.signature.value, + signing_digest(&index).expect("sign index") + ); + + for app in &index.apps { + for version in &app.versions { + let manifest_path = artifact_path(&bundle, &version.manifest_url); + let module_path = artifact_path(&bundle, &version.module_url); + ensure_digest( + "manifest", + &std::fs::read(&manifest_path).expect("read manifest"), + &version.manifest_sha256, + ) + .expect("manifest digest"); + ensure_digest( + "module", + &std::fs::read(&module_path).expect("read module"), + &version.module_sha256, + ) + .expect("module digest"); + + let manifest = read_json::(&manifest_path); + validate_manifest(&manifest).expect("validate generated manifest"); + assert!(manifest.icon.is_some()); + assert!(manifest.commands[0].docs.is_some()); + assert_eq!( + manifest.signature.value, + signing_digest(&manifest).expect("sign manifest") + ); + } + } +} + +#[test] +fn local_loopback_registry_artifacts_are_valid_for_dev() { + let mut index = + read_json::(&repo_root().join("beam-apps/fixtures/valid/index.json")); + rewrite_registry_urls(&mut index, DEFAULT_REGISTRY_URL, "http://127.0.0.1:8787"); + + validate_index(&index, "http://127.0.0.1:8787").expect("validate local index"); + validate_index(&index, DEFAULT_REGISTRY_URL).expect_err("reject wrong registry origin"); + validate_index(&index, "http://192.168.0.10:8787").expect_err("reject non-loopback http"); +} + +#[test] +fn registry_fixtures_cover_invalid_and_broad_permissions() { + let fixtures = repo_root().join("beam-apps/fixtures"); + let invalid = read_json::(&fixtures.join("invalid-digest/index.json")); + let invalid_version = &invalid.apps[0].versions[0]; + let invalid_module = artifact_path( + &fixtures.join("invalid-digest"), + &invalid_version.module_url, + ); + ensure_digest( + "module", + &std::fs::read(invalid_module).expect("read invalid module"), + &invalid_version.module_sha256, + ) + .expect_err("invalid digest"); + + let missing = std::fs::read(first_fixture_manifest(&fixtures.join("missing-fields"))) + .expect("read missing fields manifest"); + serde_json::from_slice::(&missing).expect_err("missing required field"); + + let unsupported = + read_json::(&first_fixture_manifest(&fixtures.join("unsupported-beam"))); + ensure_beam_version(&unsupported.id, &unsupported.min_beam_version) + .expect_err("unsupported beam version"); + + let malformed = read_json::(&first_fixture_manifest( + &fixtures.join("malformed-permissions"), + )); + validate_manifest(&malformed).expect_err("malformed permission"); + + let broad = read_json::(&first_fixture_manifest(&fixtures.join("broad-wildcard"))); + validate_manifest(&broad).expect("broad wildcard permission"); + assert!(broad.permissions.chains[0].contracts.is_none()); +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_json(path: &Path) -> T { + serde_json::from_slice(&std::fs::read(path).expect("read json")).expect("decode json") +} + +fn artifact_path(bundle: &Path, url: &str) -> PathBuf { + let prefix = "https://registry.beam.payy.network/"; + bundle.join(url.strip_prefix(prefix).expect("registry url")) +} + +fn rewrite_registry_urls(index: &mut RegistryIndex, from: &str, to: &str) { + let from = from.trim_end_matches('/'); + let to = to.trim_end_matches('/'); + for app in &mut index.apps { + for version in &mut app.versions { + version.manifest_url = version.manifest_url.replace(from, to); + version.module_url = version.module_url.replace(from, to); + } + } +} + +fn first_fixture_manifest(bundle: &Path) -> PathBuf { + let index = read_json::(&bundle.join("index.json")); + artifact_path(bundle, &index.apps[0].versions[0].manifest_url) +} diff --git a/pkg/beam-cli/src/tests/apps_host.rs b/pkg/beam-cli/src/tests/apps_host.rs new file mode 100644 index 0000000..b8c7b61 --- /dev/null +++ b/pkg/beam-cli/src/tests/apps_host.rs @@ -0,0 +1,195 @@ +// lint-long-file-override allow-max-lines=300 +use crate::apps::{ + approvals::{ensure_approval_executable, plan_hash}, + host::{ + ChainReadOperation, ChainReadRequest, HostTransaction, ensure_chain_read_allowed, + ensure_http_allowed, ensure_transaction_allowed, + }, + model::{ + ActionBinding, ActionPlan, AppPermissions, ApprovalRecord, ApprovalStatus, ChainOperation, + ChainPermission, HttpPermission, + }, + runtime::validate_wasm_module, + store::now, +}; + +#[test] +fn host_http_permissions_allow_declared_https_and_reject_private_hosts() { + let permissions = AppPermissions { + http: vec![HttpPermission { + url: "https://trade-api.gateway.uniswap.org/v1/*".to_string(), + }], + ..Default::default() + }; + + ensure_http_allowed( + &permissions, + "https://trade-api.gateway.uniswap.org/v1/quote", + ) + .expect("allow declared quote endpoint"); + ensure_http_allowed(&permissions, "https://127.0.0.1/v1/quote") + .expect_err("reject private host"); + ensure_http_allowed(&permissions, "https://example.com/v1/quote") + .expect_err("reject undeclared endpoint"); +} + +#[test] +fn host_transaction_permissions_enforce_selector_and_spender() { + let permissions = AppPermissions { + chains: vec![ChainPermission { + chain: "base".to_string(), + contracts: Some(vec!["0xrouter".to_string()]), + operations: vec![ChainOperation::SendTransaction], + selectors: Some(vec!["0x3593564c".to_string()]), + spenders: Some(vec!["0xspender".to_string()]), + }], + ..Default::default() + }; + let transaction = HostTransaction { + chain: "base".to_string(), + data: "0x3593564c".to_string(), + selector: Some("0x3593564c".to_string()), + spender: Some("0xspender".to_string()), + target: "0xrouter".to_string(), + value: "0".to_string(), + }; + + ensure_transaction_allowed(&permissions, &transaction, ChainOperation::SendTransaction) + .expect("allow scoped transaction"); + + let mut blocked = transaction; + blocked.selector = Some("0xdeadbeef".to_string()); + ensure_transaction_allowed(&permissions, &blocked, ChainOperation::SendTransaction) + .expect_err("reject blocked selector"); +} + +#[test] +fn host_chain_read_permissions_enforce_contract_scope() { + let permissions = AppPermissions { + chains: vec![ChainPermission { + chain: "base".to_string(), + contracts: Some(vec!["0xrouter".to_string()]), + operations: vec![ChainOperation::Read], + selectors: Some(vec!["0x70a08231".to_string()]), + spenders: None, + }], + ..Default::default() + }; + let request = ChainReadRequest { + address: None, + chain: "base".to_string(), + data: None, + operation: ChainReadOperation::Call, + owner: None, + selector: Some("0x70a08231".to_string()), + spender: None, + target: Some("0xrouter".to_string()), + token: None, + value: None, + }; + + ensure_chain_read_allowed(&permissions, &request).expect("allow scoped read"); + + let mut blocked = request; + blocked.target = Some("0xother".to_string()); + ensure_chain_read_allowed(&permissions, &blocked).expect_err("reject blocked read target"); +} + +#[test] +fn host_transaction_permissions_allow_broad_optional_globs() { + let permissions = AppPermissions { + chains: vec![ChainPermission { + chain: "base".to_string(), + contracts: None, + operations: vec![ChainOperation::SendTransaction], + selectors: None, + spenders: None, + }], + ..Default::default() + }; + let transaction = HostTransaction { + chain: "base".to_string(), + data: "0xdeadbeef".to_string(), + selector: Some("0xdeadbeef".to_string()), + spender: Some("0xspender".to_string()), + target: "0xany".to_string(), + value: "0".to_string(), + }; + + ensure_transaction_allowed(&permissions, &transaction, ChainOperation::SendTransaction) + .expect("omitted optional scopes are broad wildcards"); +} + +#[test] +fn app_runtime_requires_declared_entrypoint() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm"); + + validate_wasm_module("uniswap", "beam_app_main", &path).expect("valid app wasm"); + validate_wasm_module("uniswap", "missing_entrypoint", &path) + .expect_err("reject missing entrypoint"); +} + +#[test] +fn approval_integrity_rejects_tampered_plan() { + let mut plan = action_plan(); + let plan_hash = plan_hash(&plan).expect("hash plan"); + plan.command = "swap USDC ETH 11".to_string(); + let approval = ApprovalRecord { + id: "apr_test".to_string(), + status: ApprovalStatus::Pending, + plan, + plan_hash, + created_at: now(), + updated_at: now(), + }; + + ensure_approval_executable(&approval).expect_err("reject tampered plan"); +} + +#[test] +fn action_plan_bindings_capture_continuation_inputs() { + let plan = action_plan(); + + assert!( + plan.bindings + .iter() + .any(|binding| binding.key == "quote_id") + ); + assert!( + plan.bindings + .iter() + .any(|binding| binding.key == "swap_calldata_hash") + ); + assert!(plan.bindings.iter().any(|binding| binding.key == "router")); +} + +fn action_plan() -> ActionPlan { + ActionPlan { + app_id: "uniswap".to_string(), + app_version: "1.0.0".to_string(), + wasm_sha256: "sha256:wasm".to_string(), + manifest_sha256: "sha256:manifest".to_string(), + command: "swap USDC ETH 10".to_string(), + wallet: Some("0x1111111111111111111111111111111111111111".to_string()), + chain: "base".to_string(), + steps: Vec::new(), + bindings: vec![ + ActionBinding { + key: "quote_id".to_string(), + value: "quote-1".to_string(), + }, + ActionBinding { + key: "swap_calldata_hash".to_string(), + value: "sha256:data".to_string(), + }, + ActionBinding { + key: "router".to_string(), + value: "0x2222222222222222222222222222222222222222".to_string(), + }, + ], + constraints: Vec::new(), + expires_at: now() + 60, + } +} diff --git a/pkg/beam-cli/src/tests/cli_contract.rs b/pkg/beam-cli/src/tests/cli_contract.rs new file mode 100644 index 0000000..0d25121 --- /dev/null +++ b/pkg/beam-cli/src/tests/cli_contract.rs @@ -0,0 +1,59 @@ +use clap::Parser; + +use crate::cli::{Cli, Command, ContractAction}; + +#[test] +fn parses_contract_inspection_commands_without_code_alias() { + let info = Cli::try_parse_from([ + "beam", + "contract", + "info", + "0x1111111111111111111111111111111111111111", + ]) + .expect("parse contract info"); + assert!(matches!( + info.command, + Some(Command::Contract { + action: ContractAction::Info(args) + }) if args.address == "0x1111111111111111111111111111111111111111" + )); + + let bytecode = Cli::try_parse_from([ + "beam", + "contract", + "bytecode", + "0x1111111111111111111111111111111111111111", + "--block", + "safe", + ]) + .expect("parse contract bytecode"); + assert!(matches!( + bytecode.command, + Some(Command::Contract { + action: ContractAction::Bytecode(args) + }) if args.block.as_deref() == Some("safe") + )); + + let source = Cli::try_parse_from([ + "beam", + "contract", + "source", + "0x1111111111111111111111111111111111111111", + "Foo.sol", + ]) + .expect("parse contract source"); + assert!(matches!( + source.command, + Some(Command::Contract { + action: ContractAction::Source(args) + }) if args.source_path.as_deref() == Some("Foo.sol") + )); + + Cli::try_parse_from([ + "beam", + "contract", + "code", + "0x1111111111111111111111111111111111111111", + ]) + .expect_err("contract code alias is not supported"); +} diff --git a/pkg/beam-cli/src/tests/cli_gas.rs b/pkg/beam-cli/src/tests/cli_gas.rs new file mode 100644 index 0000000..e6d3922 --- /dev/null +++ b/pkg/beam-cli/src/tests/cli_gas.rs @@ -0,0 +1,68 @@ +use clap::Parser; + +use crate::cli::{Cli, Command, Erc20GasAction, GasAction}; + +#[test] +fn parses_gas_estimation_commands() { + let transfer = Cli::try_parse_from(["beam", "gas", "transfer", "0xrecipient", "0.01"]) + .expect("parse gas transfer"); + assert!(matches!( + transfer.command, + Some(Command::Gas { + action: GasAction::Transfer(args) + }) if args.to == "0xrecipient" && args.amount == "0.01" + )); + + let send = Cli::try_parse_from([ + "beam", + "estimate-gas", + "send", + "--value", + "0.01", + "0xcontract", + "deposit(address)", + "0xrecipient", + ]) + .expect("parse gas send"); + assert!(matches!( + send.command, + Some(Command::Gas { + action: GasAction::Send(args) + }) if args.call.contract == "0xcontract" + && args.call.function_sig == "deposit(address)" + && args.call.args == vec!["0xrecipient".to_string()] + && args.value.as_deref() == Some("0.01") + )); + + let estimate = Cli::try_parse_from(["beam", "estimate", "transfer", "0xrecipient", "1"]) + .expect("parse estimate alias"); + assert!(matches!( + estimate.command, + Some(Command::Gas { + action: GasAction::Transfer(_) + }) + )); + + let erc20 = Cli::try_parse_from([ + "beam", + "gas", + "erc20", + "approve", + "USDC", + "0xspender", + "12.5", + ]) + .expect("parse erc20 gas approve"); + assert!(matches!( + erc20.command, + Some(Command::Gas { + action: GasAction::Erc20 { + action: Erc20GasAction::Approve { + token, + spender, + amount, + }, + } + }) if token == "USDC" && spender == "0xspender" && amount == "12.5" + )); +} diff --git a/pkg/beam-cli/src/tests/evm.rs b/pkg/beam-cli/src/tests/evm.rs index 33c2ab0..7fbff3b 100644 --- a/pkg/beam-cli/src/tests/evm.rs +++ b/pkg/beam-cli/src/tests/evm.rs @@ -207,12 +207,12 @@ fn pending_transaction() -> Transaction { } #[derive(Clone, Copy)] -enum RpcScenario { +pub(super) enum RpcScenario { Confirmed, Pending, } -async fn spawn_rpc_server( +pub(super) async fn spawn_rpc_server( scenario: RpcScenario, ) -> (String, Arc>>, tokio::task::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0") @@ -255,7 +255,7 @@ async fn handle_rpc_connection( .expect("write rpc response"); } -fn rpc_methods(calls: &[Value]) -> Vec<&str> { +pub(super) fn rpc_methods(calls: &[Value]) -> Vec<&str> { calls .iter() .map(|call| call["method"].as_str().expect("rpc method")) diff --git a/pkg/beam-cli/src/tests/evm_gas.rs b/pkg/beam-cli/src/tests/evm_gas.rs new file mode 100644 index 0000000..98d22de --- /dev/null +++ b/pkg/beam-cli/src/tests/evm_gas.rs @@ -0,0 +1,53 @@ +use contracts::{Address, Client, U256}; +use serde_json::Value; +use web3::ethabi::StateMutability; + +use super::evm::{RpcScenario, rpc_methods, spawn_rpc_server}; +use crate::{ + abi::parse_function, + evm::{FunctionCall, estimate_function_gas}, +}; + +#[tokio::test] +async fn function_gas_estimation_encodes_call_without_submission() { + let (rpc_url, calls, server) = spawn_rpc_server(RpcScenario::Confirmed).await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let from = Address::from_low_u64_be(0x1234); + let contract = Address::from_low_u64_be(0xfeed); + let function = parse_function("transfer(address,uint256)", StateMutability::NonPayable) + .expect("parse function"); + let args = vec![ + format!("{:#x}", Address::from_low_u64_be(0xbeef)), + U256::from(123u64).to_string(), + ]; + + let gas = estimate_function_gas( + &client, + from, + FunctionCall { + args: &args, + contract, + function: &function, + value: U256::zero(), + }, + ) + .await + .expect("estimate function gas"); + server.abort(); + + assert_eq!(gas.gas_limit, U256::from(36_000u64)); + assert_eq!(gas.gas_price, U256::from(1_100_000_000u64)); + + let calls = calls.lock().expect("rpc calls").clone(); + assert_eq!(rpc_methods(&calls), vec!["eth_estimateGas", "eth_gasPrice"]); + let estimate = &calls[0]["params"][0]; + assert_eq!(estimate["from"], Value::String(format!("{from:#x}"))); + assert_eq!(estimate["to"], Value::String(format!("{contract:#x}"))); + assert_eq!(estimate["value"], Value::String("0x0".to_string())); + assert!( + estimate["data"] + .as_str() + .expect("encoded data") + .starts_with("0xa9059cbb") + ); +} diff --git a/pkg/beam-cli/src/tests/interactive.rs b/pkg/beam-cli/src/tests/interactive.rs index 7cf3389..179335b 100644 --- a/pkg/beam-cli/src/tests/interactive.rs +++ b/pkg/beam-cli/src/tests/interactive.rs @@ -3,7 +3,7 @@ use rustyline::highlight::Highlighter; use super::fixtures::test_app; use crate::{ - cli::{ChainAction, Command, Erc20Action, RpcAction, WalletAction}, + cli::{ChainAction, Command, Erc20Action, PrivacyAction, RpcAction, WalletAction}, commands::interactive::{ ParsedLine, is_exit_command, merge_overrides, parse_line, repl_command_args, set_repl_chain_override, should_persist_history, @@ -85,6 +85,9 @@ fn recognizes_bare_repl_shortcuts_without_breaking_cli_subcommands() { repl_command_args("balance 0xabc").expect("parse balance with address"), None ); + for command in ["privacy", "privacy address", "privacy clai", "privacy foo"] { + assert_eq!(repl_command_args(command).expect("parse privacy"), None); + } } #[test] @@ -108,6 +111,33 @@ fn interactive_parser_preserves_clap_help_for_wallet_commands() { assert!(err.render().to_string().contains("Usage: beam wallets")); } +#[test] +fn interactive_parser_routes_privacy_to_clap() { + for command in ["privacy", "privacy clai", "privacy foo"] { + let parsed = parse_line(command).expect("parse invalid privacy"); + assert!(matches!(parsed, ParsedLine::CliError(_))); + } + + let parsed = parse_line("privacy --help").expect("parse privacy help"); + let ParsedLine::CliError(err) = parsed else { + panic!("expected clap help output"); + }; + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); + assert!(!err.use_stderr()); + assert!(err.render().to_string().contains("Usage: beam privacy")); + + let parsed = parse_line("privacy address").expect("parse privacy address"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + assert!(matches!( + &cli.command, + Some(Command::Privacy { + action: PrivacyAction::Address, + }) + )); +} + #[test] fn interactive_parser_accepts_regular_cli_commands() { let parsed = parse_line("wallets create alice").expect("parse wallet create"); diff --git a/pkg/beam-cli/src/tests/interactive_autocomplete.rs b/pkg/beam-cli/src/tests/interactive_autocomplete.rs index b17d188..13a6a29 100644 --- a/pkg/beam-cli/src/tests/interactive_autocomplete.rs +++ b/pkg/beam-cli/src/tests/interactive_autocomplete.rs @@ -1,14 +1,14 @@ use rustyline::{ - Cmd, CompletionType, Context, + CompletionType, Config, Context, Editor, highlight::Highlighter, hint::Hinter, - history::{DefaultHistory, History, SearchDirection}, + history::{DefaultHistory, History}, }; use crate::commands::{ - interactive::uses_matching_prefix_history_search, interactive_helper::{BeamHelper, completion_candidates}, - interactive_history::{ReplHistory, history_navigation_command}, + interactive_history::ReplHistory, + interactive_history_navigation::bind_matching_prefix_history_search, }; #[test] @@ -34,6 +34,27 @@ fn inline_hint_prefers_matching_history_entries() { ); } +#[test] +fn inline_hint_uses_repl_history_with_navigation_state() { + let mut editor = + Editor::::with_history(Config::default(), ReplHistory::new()) + .expect("create beam repl editor"); + editor.set_helper(Some(BeamHelper::new())); + let history_navigation = bind_matching_prefix_history_search(&mut editor); + history_navigation.attach_to_history(editor.history_mut()); + editor + .add_history_entry("transfer calummoore.eth") + .expect("add transfer history"); + + let helper = editor.helper().expect("beam helper"); + let ctx = Context::new(editor.history()); + + assert_eq!( + helper.hint("transfer", "transfer".len(), &ctx), + Some(" calummoore.eth".to_string()) + ); +} + #[test] fn inline_hint_falls_back_to_completion_prefixes() { let history = DefaultHistory::new(); @@ -88,77 +109,3 @@ fn interactive_suggestions_are_dimmed() { "\u{1b}[2mwallets\u{1b}[0m" ); } - -#[test] -fn prefix_history_navigation_only_runs_for_real_prefixes_at_line_end() { - assert!(uses_matching_prefix_history_search( - "transfer", - "transfer".len() - )); - assert!(uses_matching_prefix_history_search( - "transfer ", - "transfer ".len() - )); - - assert!(!uses_matching_prefix_history_search("", 0)); - assert!(!uses_matching_prefix_history_search(" ", 3)); - assert!(!uses_matching_prefix_history_search("transfer", 3)); - assert!(!uses_matching_prefix_history_search( - "transfer\n0xabc", - "transfer\n0xabc".len() - )); -} - -#[test] -fn up_and_down_fall_back_to_history_cycling_without_a_prefix() { - assert_eq!( - history_navigation_command("", 0, SearchDirection::Reverse, 1), - Cmd::LineUpOrPreviousHistory(1) - ); - assert_eq!( - history_navigation_command("", 0, SearchDirection::Forward, 1), - Cmd::LineDownOrNextHistory(1) - ); - assert_eq!( - history_navigation_command("transfer", 3, SearchDirection::Reverse, 4), - Cmd::LineUpOrPreviousHistory(4) - ); -} - -#[test] -fn up_and_down_keep_prefix_history_search_when_typing_at_line_end() { - assert_eq!( - history_navigation_command("transfer", "transfer".len(), SearchDirection::Reverse, 1), - Cmd::HistorySearchBackward - ); - assert_eq!( - history_navigation_command("transfer", "transfer".len(), SearchDirection::Forward, 1), - Cmd::HistorySearchForward - ); -} - -#[test] -fn prefix_history_search_places_cursor_at_end_of_selected_entry() { - let mut history = ReplHistory::new(); - history - .add("transfer calummoore.eth") - .expect("add first transfer history"); - history - .add("transfer alice.eth") - .expect("add second transfer history"); - - let term = "trans"; - let reverse = history - .starts_with(term, history.len() - 1, SearchDirection::Reverse) - .expect("search reverse history") - .expect("find reverse history entry"); - assert_eq!(reverse.pos, reverse.entry.len()); - assert!(reverse.pos > term.len()); - - let forward = history - .starts_with(term, 0, SearchDirection::Forward) - .expect("search forward history") - .expect("find forward history entry"); - assert_eq!(forward.pos, forward.entry.len()); - assert!(forward.pos > term.len()); -} diff --git a/pkg/beam-cli/src/tests/keystore.rs b/pkg/beam-cli/src/tests/keystore.rs index bb5adc6..77b0e53 100644 --- a/pkg/beam-cli/src/tests/keystore.rs +++ b/pkg/beam-cli/src/tests/keystore.rs @@ -287,11 +287,10 @@ fn prompt_wallet_name_accepts_custom_input() { } #[test] -fn rejects_blank_new_passwords() { - for password in ["", " \t "] { - let err = validate_new_password(password, password) - .expect_err("reject empty or whitespace-only passwords"); +fn validates_blank_new_passwords() { + validate_new_password("", "").expect("accept empty password"); - assert!(matches!(err, Error::PasswordBlank)); - } + let err = validate_new_password(" \t ", " \t ").expect_err("reject whitespace-only password"); + + assert!(matches!(err, Error::PasswordBlank)); } diff --git a/pkg/database/tests/replit_permissions/postgres_fixture/docker.rs b/pkg/database/tests/replit_permissions/postgres_fixture/docker.rs index e6aca7e..3bff2cf 100644 --- a/pkg/database/tests/replit_permissions/postgres_fixture/docker.rs +++ b/pkg/database/tests/replit_permissions/postgres_fixture/docker.rs @@ -59,7 +59,7 @@ impl DockerPostgres { "-e", "POSTGRES_DB=postgres", "-P", - "postgres:18", + "postgres:17", ]) .output() .context("launch postgres container for replit permissions test")?; diff --git a/pkg/sourcify-client-reqwest/Cargo.toml b/pkg/sourcify-client-reqwest/Cargo.toml new file mode 100644 index 0000000..ae16a93 --- /dev/null +++ b/pkg/sourcify-client-reqwest/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sourcify-client-reqwest" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = { workspace = true } +contextful = { workspace = true } +futures-util = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } +sourcify-interface = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +workspace-hack.workspace = true + +[dev-dependencies] +httpmock = { workspace = true } +tokio = { workspace = true } diff --git a/pkg/sourcify-client-reqwest/src/client.rs b/pkg/sourcify-client-reqwest/src/client.rs new file mode 100644 index 0000000..cae1c09 --- /dev/null +++ b/pkg/sourcify-client-reqwest/src/client.rs @@ -0,0 +1,245 @@ +// lint-long-file-override allow-max-lines=250 +use std::time::Duration; + +use async_trait::async_trait; +use contextful::ResultContextExt; +use futures_util::StreamExt; +use reqwest::{StatusCode, redirect}; +use serde_json::Value; +use sourcify_interface::{ + ContractLookup, ContractRecord, ContractResponse, Error as InterfaceError, SourcifyClient, +}; +use url::Url; + +use crate::error::{Error, Result}; + +const SOURCIFY_ENDPOINT: &str = "https://sourcify.dev/server/v2"; +const USER_AGENT: &str = "beam-cli sourcify-client-reqwest"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_REDIRECTS: usize = 3; + +/// Constructor-time configuration for [`SourcifyReqwestClient`]. +#[derive(Clone, Debug, Default)] +pub enum SourcifyReqwestClientOptions { + /// Public Sourcify v2 endpoint. + #[default] + Public, + /// Custom endpoint for tests and development. + Custom { + /// Base endpoint without the `/contract//
` suffix. + endpoint: String, + }, +} + +/// Reqwest-backed implementation of [`SourcifyClient`]. +#[derive(Clone)] +pub struct SourcifyReqwestClient { + client: reqwest::Client, + endpoint: Url, +} + +impl SourcifyReqwestClient { + /// Create a client for the public Sourcify endpoint. + pub fn new(options: SourcifyReqwestClientOptions) -> Result { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .user_agent(USER_AGENT) + .redirect(redirect_policy()) + .build() + .context("build Sourcify reqwest client")?; + Self::with_reqwest_client(client, options) + } + + #[cfg(test)] + pub(crate) fn with_reqwest_client( + client: reqwest::Client, + options: SourcifyReqwestClientOptions, + ) -> Result { + let endpoint = endpoint_url(options)?; + Ok(Self { client, endpoint }) + } + + #[cfg(not(test))] + fn with_reqwest_client( + client: reqwest::Client, + options: SourcifyReqwestClientOptions, + ) -> Result { + let endpoint = endpoint_url(options)?; + Ok(Self { client, endpoint }) + } + + async fn contract_internal(&self, lookup: &ContractLookup) -> Result { + let requested_fields = lookup + .fields + .iter() + .map(ToString::to_string) + .collect::>(); + let query_fields = lookup + .fields + .iter() + .filter_map(|field| field.as_query_str().map(str::to_owned)) + .collect::>(); + let url = contract_url( + &self.endpoint, + lookup.chain_id, + &lookup.address, + &query_fields, + )?; + let response = self + .client + .get(url.clone()) + .send() + .await + .context("send Sourcify contract request")?; + let status = response.status(); + let body = read_capped_body(response, lookup.response_cap_bytes).await?; + + match status { + StatusCode::OK => parse_contract_response( + url.as_str(), + requested_fields, + lookup.chain_id, + &lookup.address, + &body, + ), + StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND + if body_indicates_unsupported_chain(&body) => + { + Err(Error::ChainUnsupported { + chain_id: lookup.chain_id, + }) + } + StatusCode::NOT_FOUND => Err(Error::NotVerified), + StatusCode::TOO_MANY_REQUESTS => Err(Error::LookupFailed { + reason: "Sourcify rate limit exceeded".to_owned(), + }), + status if status.is_server_error() => Err(Error::LookupFailed { + reason: format!("Sourcify returned HTTP {status}"), + }), + status => Err(Error::LookupFailed { + reason: format!("Sourcify returned HTTP {status}"), + }), + } + } +} + +#[async_trait] +impl SourcifyClient for SourcifyReqwestClient { + async fn contract( + &self, + lookup: &ContractLookup, + ) -> std::result::Result { + Ok(self.contract_internal(lookup).await?) + } +} + +fn endpoint_url(options: SourcifyReqwestClientOptions) -> Result { + let endpoint = match options { + SourcifyReqwestClientOptions::Public => SOURCIFY_ENDPOINT.to_owned(), + SourcifyReqwestClientOptions::Custom { endpoint } => endpoint, + }; + + Url::parse(&endpoint).map_err(|_| Error::InvalidEndpoint { endpoint }) +} + +fn contract_url( + endpoint: &Url, + chain_id: u64, + address: &str, + requested_fields: &[String], +) -> Result { + let mut url = endpoint.clone(); + { + let mut segments = url + .path_segments_mut() + .map_err(|()| Error::InvalidEndpoint { + endpoint: endpoint.to_string(), + })?; + segments + .pop_if_empty() + .push("contract") + .push(&chain_id.to_string()) + .push(address); + } + if !requested_fields.is_empty() { + url.query_pairs_mut() + .append_pair("fields", &requested_fields.join(",")); + } + Ok(url) +} + +fn redirect_policy() -> redirect::Policy { + redirect::Policy::custom(|attempt| { + if attempt.previous().len() >= MAX_REDIRECTS { + return attempt.error("too many Sourcify redirects"); + } + + if attempt.url().scheme() != "https" { + return attempt.error("Sourcify redirected to a non-HTTPS URL"); + } + + attempt.follow() + }) +} + +async fn read_capped_body(response: reqwest::Response, cap_bytes: usize) -> Result> { + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("read Sourcify response body")?; + if body.len().saturating_add(chunk.len()) > cap_bytes { + return Err(Error::ResponseTooLarge { cap_bytes }); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +fn parse_contract_response( + endpoint: &str, + requested_fields: Vec, + chain_id: u64, + address: &str, + body: &[u8], +) -> Result { + let value = serde_json::from_slice::(body).map_err(|err| Error::MalformedResponse { + reason: err.to_string(), + })?; + validate_requested_match_fields(&value, &requested_fields)?; + + let contract = serde_json::from_value::(value).map_err(|err| { + Error::MalformedResponse { + reason: err.to_string(), + } + })?; + contract + .validate_target(chain_id, address) + .map_err(|reason| Error::MalformedResponse { reason })?; + + Ok(ContractResponse { + endpoint: endpoint.to_owned(), + requested_fields, + contract, + }) +} + +fn validate_requested_match_fields(value: &Value, requested_fields: &[String]) -> Result<()> { + let object = value.as_object().ok_or_else(|| Error::MalformedResponse { + reason: "response is not a JSON object".to_owned(), + })?; + for field in requested_fields { + if matches!(field.as_str(), "creationMatch" | "runtimeMatch") && !object.contains_key(field) + { + return Err(Error::MalformedResponse { + reason: format!("{field} field is missing"), + }); + } + } + + Ok(()) +} + +fn body_indicates_unsupported_chain(body: &[u8]) -> bool { + let body = String::from_utf8_lossy(body).to_ascii_lowercase(); + body.contains("unsupported") && body.contains("chain") +} diff --git a/pkg/sourcify-client-reqwest/src/error.rs b/pkg/sourcify-client-reqwest/src/error.rs new file mode 100644 index 0000000..0a4e00a --- /dev/null +++ b/pkg/sourcify-client-reqwest/src/error.rs @@ -0,0 +1,76 @@ +use contextful::{Contextful, FromContextful, InternalError}; +use sourcify_interface::Error as InterfaceError; + +/// Result alias for reqwest Sourcify client internals. +pub type Result = std::result::Result; + +#[allow(clippy::needless_pass_by_value)] +fn map_reqwest_error(err: Contextful) -> Error { + Error::LookupFailed { + reason: err.to_string(), + } +} + +/// Reqwest Sourcify client errors. +#[derive(Debug, thiserror::Error, FromContextful)] +#[contextful(map_reqwest_error)] +pub enum Error { + /// Invalid Sourcify endpoint. + #[error("[sourcify-client-reqwest] invalid endpoint: {endpoint}")] + InvalidEndpoint { + /// Configured endpoint. + endpoint: String, + }, + + /// Sourcify does not have a verified record for the target. + #[error("[sourcify-client-reqwest] contract is not verified on Sourcify")] + NotVerified, + + /// Sourcify does not support this chain. + #[error("[sourcify-client-reqwest] Sourcify does not support chain {chain_id}")] + ChainUnsupported { + /// Selected chain id. + chain_id: u64, + }, + + /// Sourcify lookup failed. + #[error("[sourcify-client-reqwest] Sourcify lookup failed: {reason}")] + LookupFailed { + /// Human-readable failure context. + reason: String, + }, + + /// Sourcify response exceeded the configured cap. + #[error("[sourcify-client-reqwest] Sourcify response exceeded {cap_bytes} bytes")] + ResponseTooLarge { + /// Command response cap. + cap_bytes: usize, + }, + + /// Sourcify returned malformed data. + #[error("[sourcify-client-reqwest] malformed Sourcify response: {reason}")] + MalformedResponse { + /// Human-readable parse or validation context. + reason: String, + }, + + /// Internal error. + #[error("[sourcify-client-reqwest] internal error")] + Internal(#[from] InternalError), +} + +impl From for InterfaceError { + fn from(err: Error) -> Self { + match err { + Error::NotVerified => Self::NotVerified, + Error::ChainUnsupported { chain_id } => Self::ChainUnsupported { chain_id }, + Error::LookupFailed { reason } => Self::LookupFailed { reason }, + Error::ResponseTooLarge { cap_bytes } => Self::ResponseTooLarge { cap_bytes }, + Error::MalformedResponse { reason } => Self::MalformedResponse { reason }, + Error::InvalidEndpoint { endpoint } => Self::LookupFailed { + reason: format!("invalid Sourcify endpoint: {endpoint}"), + }, + Error::Internal(internal) => Self::Internal(internal), + } + } +} diff --git a/pkg/sourcify-client-reqwest/src/lib.rs b/pkg/sourcify-client-reqwest/src/lib.rs new file mode 100644 index 0000000..9a0553b --- /dev/null +++ b/pkg/sourcify-client-reqwest/src/lib.rs @@ -0,0 +1,18 @@ +#![warn(clippy::pedantic)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::match_bool)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::missing_panics_doc)] +#![allow(clippy::module_name_repetitions)] +#![deny(missing_docs)] + +//! Reqwest-backed Sourcify client. + +mod client; +mod error; + +#[cfg(test)] +mod tests; + +pub use client::{SourcifyReqwestClient, SourcifyReqwestClientOptions}; +pub use error::Error; diff --git a/pkg/sourcify-client-reqwest/src/tests/mod.rs b/pkg/sourcify-client-reqwest/src/tests/mod.rs new file mode 100644 index 0000000..388fc54 --- /dev/null +++ b/pkg/sourcify-client-reqwest/src/tests/mod.rs @@ -0,0 +1,155 @@ +use httpmock::{Method::GET, MockServer}; +use sourcify_interface::{ContractField, ContractLookup, Error, SourcifyClient}; + +use crate::{SourcifyReqwestClient, SourcifyReqwestClientOptions}; + +const ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +#[tokio::test] +async fn sends_contract_lookup_with_requested_fields() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path(format!("/contract/1/{ADDRESS}")) + .query_param("fields", "abi,creationMatch,runtimeMatch") + .header("user-agent", "beam-cli sourcify-client-reqwest"); + then.status(200).json_body_obj(&serde_json::json!({ + "chainId": "1", + "address": ADDRESS, + "match": "exact_match", + "creationMatch": null, + "runtimeMatch": "match", + "abi": [], + })); + }); + let client = test_client(&server); + + let response = client + .contract(&ContractLookup { + chain_id: 1, + address: ADDRESS.to_owned(), + fields: vec![ + ContractField::Abi, + ContractField::Match, + ContractField::CreationMatch, + ContractField::RuntimeMatch, + ], + response_cap_bytes: 1024, + }) + .await + .expect("contract response"); + mock.assert(); + + assert_eq!(response.contract.chain_id, "1"); + assert_eq!(response.contract.abi, Some(Vec::new())); +} + +#[tokio::test] +async fn maps_unsupported_chain_status_body() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(format!("/contract/999/{ADDRESS}")); + then.status(404).body("unsupported chain"); + }); + let client = test_client(&server); + + let err = client + .contract(&ContractLookup { + chain_id: 999, + address: ADDRESS.to_owned(), + fields: vec![ContractField::Match], + response_cap_bytes: 1024, + }) + .await + .expect_err("unsupported chain"); + mock.assert(); + + assert!(matches!(err, Error::ChainUnsupported { chain_id: 999 })); +} + +#[tokio::test] +async fn rejects_response_above_cap() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(format!("/contract/1/{ADDRESS}")); + then.status(200).body("x".repeat(32)); + }); + let client = test_client(&server); + + let err = client + .contract(&ContractLookup { + chain_id: 1, + address: ADDRESS.to_owned(), + fields: vec![ContractField::Match], + response_cap_bytes: 8, + }) + .await + .expect_err("response cap"); + mock.assert(); + + assert!(matches!(err, Error::ResponseTooLarge { cap_bytes: 8 })); +} + +#[tokio::test] +async fn rejects_malformed_json() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(format!("/contract/1/{ADDRESS}")); + then.status(200).body("{not-json"); + }); + let client = test_client(&server); + + let err = client + .contract(&ContractLookup { + chain_id: 1, + address: ADDRESS.to_owned(), + fields: vec![ContractField::Match], + response_cap_bytes: 1024, + }) + .await + .expect_err("malformed json"); + mock.assert(); + + assert!(matches!(err, Error::MalformedResponse { .. })); +} + +#[tokio::test] +async fn rejects_missing_requested_nullable_match_fields() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path(format!("/contract/1/{ADDRESS}")) + .query_param("fields", "creationMatch,runtimeMatch"); + then.status(200).json_body_obj(&serde_json::json!({ + "chainId": "1", + "address": ADDRESS, + "match": "exact_match", + "creationMatch": null, + })); + }); + let client = test_client(&server); + + let err = client + .contract(&ContractLookup { + chain_id: 1, + address: ADDRESS.to_owned(), + fields: vec![ + ContractField::Match, + ContractField::CreationMatch, + ContractField::RuntimeMatch, + ], + response_cap_bytes: 1024, + }) + .await + .expect_err("missing runtimeMatch"); + mock.assert(); + + assert!(matches!(err, Error::MalformedResponse { .. })); +} + +fn test_client(server: &MockServer) -> SourcifyReqwestClient { + SourcifyReqwestClient::new(SourcifyReqwestClientOptions::Custom { + endpoint: server.base_url(), + }) + .expect("test Sourcify client") +} diff --git a/pkg/sourcify-interface/Cargo.toml b/pkg/sourcify-interface/Cargo.toml new file mode 100644 index 0000000..28fda9a --- /dev/null +++ b/pkg/sourcify-interface/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sourcify-interface" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = { workspace = true } +contextful = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +workspace-hack.workspace = true diff --git a/pkg/sourcify-interface/src/client.rs b/pkg/sourcify-interface/src/client.rs new file mode 100644 index 0000000..dfb8b0c --- /dev/null +++ b/pkg/sourcify-interface/src/client.rs @@ -0,0 +1,23 @@ +use async_trait::async_trait; + +use crate::{ContractField, ContractResponse, Result}; + +/// Request for a Sourcify v2 contract lookup. +#[derive(Clone, Debug)] +pub struct ContractLookup { + /// Decimal chain id. + pub chain_id: u64, + /// EIP-55 checksum address. + pub address: String, + /// Requested Sourcify fields. + pub fields: Vec, + /// Maximum decoded response bytes. + pub response_cap_bytes: usize, +} + +/// Sourcify contract lookup interface. +#[async_trait] +pub trait SourcifyClient: Send + Sync + 'static { + /// Fetch a Sourcify v2 contract record. + async fn contract(&self, lookup: &ContractLookup) -> Result; +} diff --git a/pkg/sourcify-interface/src/contract.rs b/pkg/sourcify-interface/src/contract.rs new file mode 100644 index 0000000..a99cebd --- /dev/null +++ b/pkg/sourcify-interface/src/contract.rs @@ -0,0 +1,232 @@ +// lint-long-file-override allow-max-lines=250 +use std::{collections::BTreeMap, fmt}; + +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +/// Sourcify v2 contract fields supported by Beam. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContractField { + /// ABI JSON array. + Abi, + /// Compilation summary. + Compilation, + /// Creation-bytecode match state. + CreationMatch, + /// Overall match state. + /// + /// Sourcify returns this field in contract responses, but does not accept it as a `fields` + /// selector. + Match, + /// Metadata JSON. + Metadata, + /// Proxy resolution summary. + ProxyResolution, + /// Runtime-bytecode match state. + RuntimeMatch, + /// Solidity standard JSON input. + StandardJsonInput, + /// Source file map. + Sources, + /// Verification timestamp. + VerifiedAt, +} + +impl ContractField { + /// Sourcify v2 response field name. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Abi => "abi", + Self::Compilation => "compilation", + Self::CreationMatch => "creationMatch", + Self::Match => "match", + Self::Metadata => "metadata", + Self::ProxyResolution => "proxyResolution", + Self::RuntimeMatch => "runtimeMatch", + Self::StandardJsonInput => "stdJsonInput", + Self::Sources => "sources", + Self::VerifiedAt => "verifiedAt", + } + } + + /// Sourcify v2 query field name, when this field is requestable. + #[must_use] + pub fn as_query_str(self) -> Option<&'static str> { + match self { + Self::Match => None, + _ => Some(self.as_str()), + } + } +} + +impl fmt::Display for ContractField { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Sourcify match state for accepted non-null match values. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MatchState { + /// Runtime or creation bytecode matched exactly. + ExactMatch, + /// Runtime or creation bytecode matched after Sourcify transformations. + Match, +} + +impl MatchState { + /// Whether this state is accepted as runtime verification. + #[must_use] + pub fn is_runtime_verified(self) -> bool { + matches!(self, Self::ExactMatch | Self::Match) + } + + /// Sourcify string representation. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ExactMatch => "exact_match", + Self::Match => "match", + } + } +} + +impl<'de> Deserialize<'de> for MatchState { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "exact_match" => Ok(Self::ExactMatch), + "match" => Ok(Self::Match), + other => Err(serde::de::Error::custom(format!( + "unknown Sourcify match state `{other}`" + ))), + } + } +} + +impl fmt::Display for MatchState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Source file entry returned by Sourcify. +#[derive(Clone, Debug, Deserialize)] +pub struct SourceFile { + /// Decoded UTF-8 source content. + pub content: String, +} + +/// Compiler and contract summary returned by Sourcify. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompilationSummary { + /// Compiler version or identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compiler: Option, + /// Contract language. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub language: Option, + /// Contract name. + #[serde( + default, + alias = "contractName", + alias = "name", + skip_serializing_if = "Option::is_none" + )] + pub contract_name: Option, +} + +/// Typed common Sourcify v2 contract record. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContractRecord { + /// Decimal chain id string from Sourcify. + pub chain_id: String, + /// Address string from Sourcify. + pub address: String, + /// Overall match state. + #[serde(rename = "match")] + pub match_state: MatchState, + /// Creation match state. + pub creation_match: Option, + /// Runtime match state. + pub runtime_match: Option, + /// Verification timestamp. + #[serde(default)] + pub verified_at: Option, + /// ABI JSON array. + #[serde(default, deserialize_with = "deserialize_optional_array")] + pub abi: Option>, + /// Source files keyed by Sourcify source path. + #[serde(default)] + pub sources: Option>, + /// Metadata JSON object or JSON string. + #[serde(default)] + pub metadata: Option, + /// Solidity standard JSON input. + #[serde(default, rename = "stdJsonInput")] + pub standard_json_input: Option, + /// Compilation summary. + #[serde(default)] + pub compilation: Option, + /// Raw proxy resolution object. + #[serde(default)] + pub proxy_resolution: Option, +} + +impl ContractRecord { + /// Validates that Sourcify echoed the requested target. + pub fn validate_target(&self, chain_id: u64, address: &str) -> Result<(), String> { + let response_chain_id = self + .chain_id + .parse::() + .map_err(|_| "chainId is not a decimal string".to_owned())?; + if response_chain_id != chain_id { + return Err(format!( + "chainId mismatch: expected {chain_id}, got {response_chain_id}" + )); + } + + if !self.address.eq_ignore_ascii_case(address) { + return Err(format!( + "address mismatch: expected {address}, got {}", + self.address + )); + } + + Ok(()) + } +} + +/// Response metadata and typed contract record. +#[derive(Clone, Debug)] +pub struct ContractResponse { + /// Final URL used for the request. + pub endpoint: String, + /// Sourcify fields requested. + pub requested_fields: Vec, + /// Contract record. + pub contract: ContractRecord, +} + +fn deserialize_optional_array<'de, D>( + deserializer: D, +) -> std::result::Result>, D::Error> +where + D: Deserializer<'de>, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + + match value { + Value::Array(values) => Ok(Some(values)), + _ => Ok(None), + } +} diff --git a/pkg/sourcify-interface/src/error.rs b/pkg/sourcify-interface/src/error.rs new file mode 100644 index 0000000..0df6013 --- /dev/null +++ b/pkg/sourcify-interface/src/error.rs @@ -0,0 +1,44 @@ +use contextful::{FromContextful, InternalError}; + +/// Result alias for Sourcify interface operations. +pub type Result = std::result::Result; + +/// Stable Sourcify lookup errors. +#[derive(Debug, thiserror::Error, FromContextful)] +pub enum Error { + /// Sourcify does not have a verified record for the target. + #[error("[sourcify-interface] contract is not verified on Sourcify")] + NotVerified, + + /// Sourcify does not support this chain. + #[error("[sourcify-interface] Sourcify does not support chain {chain_id}")] + ChainUnsupported { + /// Selected chain id. + chain_id: u64, + }, + + /// Sourcify lookup failed at the transport or service layer. + #[error("[sourcify-interface] Sourcify lookup failed: {reason}")] + LookupFailed { + /// Human-readable failure context. + reason: String, + }, + + /// Sourcify response exceeded the configured cap. + #[error("[sourcify-interface] Sourcify response exceeded {cap_bytes} bytes")] + ResponseTooLarge { + /// Command response cap. + cap_bytes: usize, + }, + + /// Sourcify returned an invalid response shape. + #[error("[sourcify-interface] malformed Sourcify response: {reason}")] + MalformedResponse { + /// Human-readable parse or validation context. + reason: String, + }, + + /// Internal error. + #[error("[sourcify-interface] internal error")] + Internal(#[from] InternalError), +} diff --git a/pkg/sourcify-interface/src/lib.rs b/pkg/sourcify-interface/src/lib.rs new file mode 100644 index 0000000..c79d6ec --- /dev/null +++ b/pkg/sourcify-interface/src/lib.rs @@ -0,0 +1,19 @@ +#![warn(clippy::pedantic)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::match_bool)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::missing_panics_doc)] +#![allow(clippy::module_name_repetitions)] +#![deny(missing_docs)] + +//! Typed interface for Sourcify contract lookups. + +mod client; +mod contract; +mod error; + +pub use client::{ContractLookup, SourcifyClient}; +pub use contract::{ + CompilationSummary, ContractField, ContractRecord, ContractResponse, MatchState, SourceFile, +}; +pub use error::{Error, Result}; diff --git a/pkg/workspace-hack/Cargo.toml b/pkg/workspace-hack/Cargo.toml index bcb71df..06144a6 100644 --- a/pkg/workspace-hack/Cargo.toml +++ b/pkg/workspace-hack/Cargo.toml @@ -107,6 +107,7 @@ keccak = { version = "0.1", default-features = false, features = ["asm"] } lalrpop-util = { version = "0.20" } lazy_static = { version = "1", default-features = false, features = ["spin_no_std"] } libc = { version = "0.2" } +libm = { version = "0.2" } libz-sys = { version = "1", default-features = false, features = ["libc", "static"] } lock_api = { version = "0.4", features = ["arc_lock", "serde"] } log = { version = "0.4", default-features = false, features = ["std"] } @@ -165,6 +166,7 @@ similar = { version = "2", features = ["inline"] } smallvec = { version = "1", default-features = false, features = ["const_new", "serde", "union"] } socket2-3b31131e45eafb45 = { package = "socket2", version = "0.6", default-features = false, features = ["all"] } socket2-d8f496e17d97b5cb = { package = "socket2", version = "0.5", default-features = false, features = ["all"] } +spin = { version = "0.9", default-features = false, features = ["once", "rwlock", "spin_mutex", "std"] } strum = { version = "0.27", features = ["derive"] } subtle = { version = "2" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } @@ -299,6 +301,7 @@ keccak = { version = "0.1", default-features = false, features = ["asm"] } lalrpop-util = { version = "0.20" } lazy_static = { version = "1", default-features = false, features = ["spin_no_std"] } libc = { version = "0.2" } +libm = { version = "0.2" } libz-sys = { version = "1", default-features = false, features = ["libc", "static"] } lock_api = { version = "0.4", features = ["arc_lock", "serde"] } log = { version = "0.4", default-features = false, features = ["std"] } @@ -360,6 +363,7 @@ similar = { version = "2", features = ["inline"] } smallvec = { version = "1", default-features = false, features = ["const_new", "serde", "union"] } socket2-3b31131e45eafb45 = { package = "socket2", version = "0.6", default-features = false, features = ["all"] } socket2-d8f496e17d97b5cb = { package = "socket2", version = "0.5", default-features = false, features = ["all"] } +spin = { version = "0.9", default-features = false, features = ["once", "rwlock", "spin_mutex", "std"] } strum = { version = "0.27", features = ["derive"] } subtle = { version = "2" } syn-dff4ba8e3ae991db = { package = "syn", version = "1", features = ["extra-traits", "full", "visit"] } diff --git a/pkg/xtask/src/setup/postgres.rs b/pkg/xtask/src/setup/postgres.rs index f3bcd03..b306732 100644 --- a/pkg/xtask/src/setup/postgres.rs +++ b/pkg/xtask/src/setup/postgres.rs @@ -10,7 +10,7 @@ use crate::error::{Result, XTaskError}; use crate::setup::{SetupArgs, path_to_string, run_expression, run_expression_unchecked}; -pub const DEFAULT_IMAGE: &str = "postgres:18"; +pub const DEFAULT_IMAGE: &str = "postgres:17"; pub const DEFAULT_CONTAINER: &str = "polybase-pg"; pub struct PostgresOutcome {