diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ccbcfdb..c6dc2b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -238,6 +238,7 @@ jobs: - name: Run Clippy run: | cargo hack clippy \ + --locked \ --workspace --all-targets \ --feature-powerset \ --exclude-features bb_rs,bb_utxo diff --git a/.gitignore b/.gitignore index 05d6f44..a85fb2f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,3 @@ temp_fixtures # Claude Code completion markers .done - -# Symlink to GENERATED_AI_GUIDANCE.md (canonical file) -CLAUDE.md diff --git a/Cargo.lock b/Cargo.lock index 41a6cb1..f531598 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2402,6 +2402,7 @@ version = "0.1.0" dependencies = [ "argon2", "async-trait", + "base64 0.22.1", "clap", "contextful", "contracts", @@ -3992,7 +3993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 2.0.112", + "syn 1.0.109", ] [[package]] @@ -4762,7 +4763,7 @@ dependencies = [ "proptest", "rand 0.8.5", "rand_chacha 0.3.1", - "rand_xorshift 0.3.0", + "rand_xorshift 0.4.0", "serde", "serde_json", "test-strategy", @@ -5878,6 +5879,8 @@ dependencies = [ "paste", "posthog", "posthog-interface", + "price-cache-interface", + "price-cache-pg", "primitives", "providers-interface", "push-notification-expo", @@ -5915,6 +5918,7 @@ dependencies = [ "support-rpc", "support-storage-interface", "support-storage-pg", + "swap-pricer-price-cache", "tempfile", "testutil", "thiserror 1.0.69", @@ -5939,10 +5943,12 @@ dependencies = [ "client-http", "client-http-longpoll", "contextful", + "currency", "element", "guild-interface", "http-interface", "parking_lot 0.12.5", + "price-cache-interface", "ramps-interface", "reqwest 0.12.28", "rpc", @@ -5966,7 +5972,9 @@ dependencies = [ "element", "ethereum-types", "kyc", + "network", "notes-interface", + "price-cache-interface", "primitives", "ramps-interface", "rpc", @@ -6049,7 +6057,7 @@ dependencies = [ "insta", "rand 0.8.5", "rand_chacha 0.3.1", - "rand_xorshift 0.3.0", + "rand_xorshift 0.4.0", "serde", "workspace-hack", ] @@ -6064,7 +6072,7 @@ dependencies = [ "minimal-poseidon", "rand 0.8.5", "rand_chacha 0.3.1", - "rand_xorshift 0.3.0", + "rand_xorshift 0.4.0", "serde", "workspace-hack", ] @@ -8440,7 +8448,6 @@ dependencies = [ "element", "eth-util", "ethereum-types", - "futures", "hex", "itertools 0.14.0", "node-client-http", @@ -11075,6 +11082,7 @@ dependencies = [ "deadpool", "diesel", "diesel-async", + "price-cache-interface", "reqwest 0.12.28", "rpc", "serde", @@ -11088,6 +11096,51 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "price-cache-http" +version = "0.1.0" +dependencies = [ + "async-trait", + "contextful", + "currency", + "guild-client-http", + "guild-interface", + "price-cache-interface", + "workspace-hack", +] + +[[package]] +name = "price-cache-interface" +version = "0.1.0" +dependencies = [ + "async-trait", + "bigdecimal", + "chrono", + "contextful", + "currency", + "serde", + "thiserror 1.0.69", + "unimock", + "workspace-hack", +] + +[[package]] +name = "price-cache-pg" +version = "0.1.0" +dependencies = [ + "async-trait", + "bigdecimal", + "chrono", + "contextful", + "currency", + "database", + "diesel", + "diesel-async", + "price-cache-interface", + "thiserror 1.0.69", + "workspace-hack", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -11306,9 +11359,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", @@ -11913,6 +11966,7 @@ dependencies = [ "test-spy", "thiserror 1.0.69", "ts-rs", + "unimock", "uuid 1.19.0", "veil", "workspace-hack", @@ -16508,9 +16562,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" dependencies = [ "serde_core", ] @@ -17491,6 +17545,25 @@ dependencies = [ "zip", ] +[[package]] +name = "swap-pricer-price-cache" +version = "0.1.0" +dependencies = [ + "async-trait", + "bigdecimal", + "chrono", + "contextful", + "currency", + "element", + "ethnum", + "hex", + "price-cache-interface", + "ramps-interface", + "thiserror 1.0.69", + "workspace-hack", + "zk-primitives", +] + [[package]] name = "swc_atoms" version = "9.0.0" @@ -18172,7 +18245,7 @@ checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.0", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -19211,6 +19284,7 @@ dependencies = [ "async-trait", "barretenberg-cli", "chrono", + "client-http", "contextful", "contracts", "element", @@ -19222,18 +19296,24 @@ dependencies = [ "guild-client-http", "guild-interface", "hex", + "http-interface", "json-store", "node-client-http", "node-interface", "parking_lot 0.12.5", "payy-note", + "price-cache-http", + "price-cache-interface", "primitives", + "ramps-interface", "rand 0.8.5", "reqwest 0.12.28", + "rpc", "secp256k1 0.28.2", "serde", "serde_json", "sha3", + "swap-pricer-price-cache", "tempfile", "test-spy", "thiserror 1.0.69", @@ -20452,7 +20532,7 @@ dependencies = [ "serde", "serde_core", "serde_json", - "serde_spanned 1.0.4", + "serde_spanned 1.1.0", "serde_with", "sha1", "sha2", diff --git a/Cargo.toml b/Cargo.toml index dbe23a2..8e7ef2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,9 @@ support = { path = "./pkg/support" } p2p = { path = "./pkg/p2p" } p2p2 = { path = "./pkg/p2p2" } parse-link = { path = "./pkg/parse-link" } +price-cache-http = { path = "./pkg/price-cache-http" } +price-cache-interface = { path = "./pkg/price-cache-interface" } +price-cache-pg = { path = "./pkg/price-cache-pg" } posthog-interface = { path = "./pkg/posthog-interface" } posthog = { path = "./pkg/posthog" } kyc = { path = "./pkg/kyc" } @@ -123,6 +126,7 @@ bungee-interface = { path = "./pkg/bungee-interface" } bungee-client-http = { path = "./pkg/bungee-client-http" } slack-client-interface = { path = "./pkg/slack-client-interface" } slack-client-http = { path = "./pkg/slack-client-http" } +swap-pricer-price-cache = { path = "./pkg/swap-pricer-price-cache" } ramps-interface = { path = "./pkg/ramps-interface" } ramps-providers-interface = { path = "./pkg/ramps-providers-interface" } ramps-storage-interface = { path = "./pkg/ramps-storage-interface" } @@ -271,14 +275,14 @@ once_cell = "1.19.0" parking_lot = "0.12.1" phonenumber = "0.3" pretty-hex = "0.3.0" -proptest = "1.6.0" +proptest = "1.11.0" proc-macro2 = "1.0" quote = "1.0" quickcheck = "1.0.3" rand = "0.8.5" rand_chacha = "0.3.1" -rand_xorshift = "0.3" -reqwest = { version = "0.12", features = ["json", "multipart"] } +rand_xorshift = "0.4" +reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } rlp = "0.6.1" rocksdb = "0.21" rpassword = "7.4.0" diff --git a/README.md b/README.md index 1503950..d49b0f5 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,7 @@ cargo hakari manage-deps --yes ``` The `Rust / Hakari Check` GitHub workflow enforces that the crate stays synchronized; if it fails, re-run the commands above and commit the resulting changes. +The main `Test` workflow also verifies `Cargo.lock` during its clippy run by adding `--locked` to `cargo hack clippy`; if that check reports that the lockfile needs updates, regenerate and commit `Cargo.lock` before retrying CI. ## Contributing diff --git a/app/packages/payy/scripts/export-ts-types.sh b/app/packages/payy/scripts/export-ts-types.sh index 81a581e..84fe7b0 100755 --- a/app/packages/payy/scripts/export-ts-types.sh +++ b/app/packages/payy/scripts/export-ts-types.sh @@ -15,10 +15,15 @@ echo "📋 Checking generated types ..." # Type-check only the generated TypeScript types using the custom tsconfig cd "${workspace_root}/app/packages/payy" + +# Keep this aligned with the app TypeScript toolchain (see app/yarn.lock). +TS_RS_TS_VERSION="5.9.3" +echo "Using TypeScript ${TS_RS_TS_VERSION} for generated-bindings type checking." + # Note: this expects at least one top-level `src/ts-rs-bindings/*.ts` file. # If we adopt namespaced exports via `#[ts(export_to = ".../")]`, revisit this # check and `src/ts-rs-bindings/tsconfig.tsrs.json` include patterns. -if ls src/ts-rs-bindings/*.ts && npx tsc --project src/ts-rs-bindings/tsconfig.tsrs.json; then +if ls src/ts-rs-bindings/*.ts && npx --yes --package typescript@${TS_RS_TS_VERSION} tsc --project src/ts-rs-bindings/tsconfig.tsrs.json; then echo "✅ TypeScript type check passed for generated bindings." else echo "❌ TypeScript type check failed for generated bindings!" diff --git a/app/packages/payy/src/ts-rs-bindings/ActivityExternalData.ts b/app/packages/payy/src/ts-rs-bindings/ActivityExternalData.ts index f850947..1a446df 100644 --- a/app/packages/payy/src/ts-rs-bindings/ActivityExternalData.ts +++ b/app/packages/payy/src/ts-rs-bindings/ActivityExternalData.ts @@ -11,6 +11,7 @@ import type { RampWithdrawExternal } from "./RampWithdrawExternal"; import type { SendLinkExternal } from "./SendLinkExternal"; import type { SendRegistryExternal } from "./SendRegistryExternal"; import type { SupportExternal } from "./SupportExternal"; +import type { SwapExternal } from "./SwapExternal"; import type { WorkerStatus } from "./WorkerStatus"; export type ActivityExternalData = @@ -73,6 +74,7 @@ export type ActivityExternalData = | { "kind": "RAMP_DEPOSIT_V1"; "data": RampDepositExternal } | { "kind": "RAMP_DEPOSIT_LINK_V1"; "data": NoExternalData } | { "kind": "RAMP_WITHDRAW_V1"; "data": RampWithdrawExternal } + | { "kind": "SWAP_V1"; "data": SwapExternal } | { "kind": "SUPPORT_V1"; "data": SupportExternal } | { "kind": "MIGRATE_V0"; "data": NoExternalData } | { "kind": "WALLET_V0"; "data": NoExternalData } diff --git a/app/packages/payy/src/ts-rs-bindings/Command.ts b/app/packages/payy/src/ts-rs-bindings/Command.ts index 7f48b18..69ed86f 100644 --- a/app/packages/payy/src/ts-rs-bindings/Command.ts +++ b/app/packages/payy/src/ts-rs-bindings/Command.ts @@ -13,6 +13,7 @@ import type { GetBungeeTokenListInput } from "./GetBungeeTokenListInput"; import type { GetDepositPendingBalanceLongPollInput } from "./GetDepositPendingBalanceLongPollInput"; import type { GetMintBalanceInput } from "./GetMintBalanceInput"; import type { GetUnclaimedBalanceLongPollInput } from "./GetUnclaimedBalanceLongPollInput"; +import type { GetYieldBalanceLongPollInput } from "./GetYieldBalanceLongPollInput"; import type { ListActivityInput } from "./ListActivityInput"; import type { ListActivityLongPollInput } from "./ListActivityLongPollInput"; import type { LoadWalletInput } from "./LoadWalletInput"; @@ -28,6 +29,7 @@ import type { RetryActivityInput } from "./RetryActivityInput"; import type { SendLinkInput } from "./SendLinkInput"; import type { SendRegistryInput } from "./SendRegistryInput"; import type { SignInput } from "./SignInput"; +import type { TreasurySwapInput } from "./TreasurySwapInput"; export type Command = | { "cmd": "logs" } @@ -49,6 +51,13 @@ export type Command = | { "cmd": "retry_activity"; "params": RetryActivityInput } | { "cmd": "get_balance" } | { "cmd": "get_balance_long_poll"; "params": GetBalanceLongPollInput } + | { "cmd": "get_yield_balance" } + | { + "cmd": "get_yield_balance_long_poll"; + "params": GetYieldBalanceLongPollInput; + } + | { "cmd": "get_yield_position" } + | { "cmd": "treasury_swap"; "params": TreasurySwapInput } | { "cmd": "get_deposit_pending_balance" } | { "cmd": "get_deposit_pending_balance_long_poll"; diff --git a/app/packages/payy/src/ts-rs-bindings/GetYieldBalanceLongPollInput.ts b/app/packages/payy/src/ts-rs-bindings/GetYieldBalanceLongPollInput.ts new file mode 100644 index 0000000..8ede124 --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/GetYieldBalanceLongPollInput.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type GetYieldBalanceLongPollInput = { + last_modified: string | null; + timeout_secs: number | null; +}; diff --git a/app/packages/payy/src/ts-rs-bindings/GetYieldBalanceLongPollOutput.ts b/app/packages/payy/src/ts-rs-bindings/GetYieldBalanceLongPollOutput.ts new file mode 100644 index 0000000..cefc22f --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/GetYieldBalanceLongPollOutput.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Element } from "./Element"; + +export type GetYieldBalanceLongPollOutput = { + value: Element; + last_modified: string; +}; diff --git a/app/packages/payy/src/ts-rs-bindings/InvestViewModel.ts b/app/packages/payy/src/ts-rs-bindings/InvestViewModel.ts index b19d0be..b6e3d37 100644 --- a/app/packages/payy/src/ts-rs-bindings/InvestViewModel.ts +++ b/app/packages/payy/src/ts-rs-bindings/InvestViewModel.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LoadingResult } from "./LoadingResult"; -export type InvestViewModel = Record; +export type InvestViewModel = { + portfolioBalance: LoadingResult; + allTimeGains: LoadingResult; +}; diff --git a/app/packages/payy/src/ts-rs-bindings/Provider.ts b/app/packages/payy/src/ts-rs-bindings/Provider.ts index c07b0b8..9525d2c 100644 --- a/app/packages/payy/src/ts-rs-bindings/Provider.ts +++ b/app/packages/payy/src/ts-rs-bindings/Provider.ts @@ -3,6 +3,7 @@ export type Provider = | "ALFRED" | "MANTECA" + | "PAYY" | "RAIN" | "SUMSUB" | "CYBRID" diff --git a/app/packages/payy/src/ts-rs-bindings/RampSwapTransaction.ts b/app/packages/payy/src/ts-rs-bindings/RampSwapTransaction.ts new file mode 100644 index 0000000..a2d30ca --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/RampSwapTransaction.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Element } from "./Element"; +import type { SwapStatus } from "./SwapStatus"; + +export type RampSwapTransaction = { + status: SwapStatus; + from_note_kind: Element; + to_note_kind: Element; +}; diff --git a/app/packages/payy/src/ts-rs-bindings/SwapExternal.ts b/app/packages/payy/src/ts-rs-bindings/SwapExternal.ts new file mode 100644 index 0000000..28dd950 --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/SwapExternal.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SwapExternal = { transaction_id: string | null }; diff --git a/app/packages/payy/src/ts-rs-bindings/SwapStatus.ts b/app/packages/payy/src/ts-rs-bindings/SwapStatus.ts new file mode 100644 index 0000000..e596dfa --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/SwapStatus.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SwapStatus = "PENDING" | "FUNDED" | "COMPLETE" | "FAILED"; diff --git a/app/packages/payy/src/ts-rs-bindings/TreasurySwapDirection.ts b/app/packages/payy/src/ts-rs-bindings/TreasurySwapDirection.ts new file mode 100644 index 0000000..079b0d1 --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/TreasurySwapDirection.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TreasurySwapDirection = "INVEST" | "WITHDRAW"; diff --git a/app/packages/payy/src/ts-rs-bindings/TreasurySwapInput.ts b/app/packages/payy/src/ts-rs-bindings/TreasurySwapInput.ts new file mode 100644 index 0000000..40a2ba3 --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/TreasurySwapInput.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Element } from "./Element"; +import type { TreasurySwapDirection } from "./TreasurySwapDirection"; + +export type TreasurySwapInput = { + amount: Element; + direction: TreasurySwapDirection; +}; diff --git a/app/packages/payy/src/ts-rs-bindings/WalletEvent.ts b/app/packages/payy/src/ts-rs-bindings/WalletEvent.ts index a36ecd3..c1d976d 100644 --- a/app/packages/payy/src/ts-rs-bindings/WalletEvent.ts +++ b/app/packages/payy/src/ts-rs-bindings/WalletEvent.ts @@ -7,4 +7,5 @@ export type WalletEvent = | { "type": "onBackupPress"; "payload": Record } | { "type": "onSendPress"; "payload": Record } | { "type": "onRequestPress"; "payload": Record } + | { "type": "onYieldPress"; "payload": Record } | { "type": "onCardBannerPress"; "payload": Record }; diff --git a/app/packages/payy/src/ts-rs-bindings/WalletViewModel.ts b/app/packages/payy/src/ts-rs-bindings/WalletViewModel.ts index 705d62b..78bea1f 100644 --- a/app/packages/payy/src/ts-rs-bindings/WalletViewModel.ts +++ b/app/packages/payy/src/ts-rs-bindings/WalletViewModel.ts @@ -1,6 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { LoadingResult } from "./LoadingResult"; import type { WalletCardBanner } from "./WalletCardBanner"; +import type { WalletYieldBanner } from "./WalletYieldBanner"; export type WalletViewModel = { isBalanceVisible: boolean; @@ -14,5 +15,6 @@ export type WalletViewModel = { balance: LoadingResult; depositPendingBalance: LoadingResult; unclaimedLinksBalance: LoadingResult; + yieldBanner: WalletYieldBanner; cardBanner: WalletCardBanner; }; diff --git a/app/packages/payy/src/ts-rs-bindings/WalletYieldBanner.ts b/app/packages/payy/src/ts-rs-bindings/WalletYieldBanner.ts new file mode 100644 index 0000000..113f61c --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/WalletYieldBanner.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LoadingResult } from "./LoadingResult"; + +export type WalletYieldBanner = + | { "type": "hidden"; "payload": Record } + | { "type": "cta"; "payload": Record } + | { + "type": "balance"; + "payload": { value: LoadingResult }; + }; diff --git a/app/packages/payy/src/ts-rs-bindings/YieldBalanceOutput.ts b/app/packages/payy/src/ts-rs-bindings/YieldBalanceOutput.ts new file mode 100644 index 0000000..5c6b61f --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/YieldBalanceOutput.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Element } from "./Element"; + +export type YieldBalanceOutput = { value: Element }; diff --git a/app/packages/payy/src/ts-rs-bindings/YieldPositionOutput.ts b/app/packages/payy/src/ts-rs-bindings/YieldPositionOutput.ts new file mode 100644 index 0000000..fbdce93 --- /dev/null +++ b/app/packages/payy/src/ts-rs-bindings/YieldPositionOutput.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Element } from "./Element"; + +export type YieldPositionOutput = { + invested_total: Element; + withdrawn_total: Element; +}; diff --git a/docker/Dockerfile.aggregator b/docker/Dockerfile.aggregator index f9b18c2..d7bb0fc 100644 --- a/docker/Dockerfile.aggregator +++ b/docker/Dockerfile.aggregator @@ -1,4 +1,6 @@ # Build aggregator CLI binary +ARG DEBIAN_TESTING_SNAPSHOT=20260404T140000Z + FROM rust:1-bookworm AS workspace ARG SCCACHE_GCS_BUCKET @@ -75,6 +77,8 @@ RUN mkdir -p /build/bin && \ # Runtime image with barretenberg CLI installed FROM debian:bookworm-slim as runtime +ARG DEBIAN_TESTING_SNAPSHOT + ENV ROOT_DIR /polybase WORKDIR $ROOT_DIR @@ -93,12 +97,13 @@ RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20 mv bb /usr/local/bin/bb && \ rm barretenberg.tar.gz -# Fetch modern libc/libstdc++ plus jq which bb expects -RUN echo 'deb http://deb.debian.org/debian testing main' \ - > /etc/apt/sources.list.d/testing.list && \ - echo 'APT::Default-Release "stable";' \ - > /etc/apt/apt.conf.d/99defaultrelease && \ - apt-get update && \ +# Freeze Debian testing to a known-good snapshot so upstream testing changes +# do not break image builds. +RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${DEBIAN_TESTING_SNAPSHOT} testing main" \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive \ apt-get install -y -t testing libc6 libstdc++6 jq diff --git a/docker/Dockerfile.barretenberg-api-server b/docker/Dockerfile.barretenberg-api-server index f9cf234..70e6c8e 100644 --- a/docker/Dockerfile.barretenberg-api-server +++ b/docker/Dockerfile.barretenberg-api-server @@ -1,4 +1,6 @@ # Build binary +ARG DEBIAN_TESTING_SNAPSHOT=20260404T140000Z + FROM rust:1-bookworm AS workspace ARG SCCACHE_GCS_BUCKET @@ -37,6 +39,8 @@ WORKDIR /build FROM workspace AS tester +ARG DEBIAN_TESTING_SNAPSHOT + SHELL ["/bin/bash", "--login", "-c"] RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash @@ -53,15 +57,14 @@ RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20 mv bb /usr/local/bin/bb && \ rm barretenberg.tar.gz -# bb requires a recent glibcxx version -# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) -# also installs jq, some bb commands require jq -RUN echo 'deb http://deb.debian.org/debian testing main' \ - > /etc/apt/sources.list.d/testing.list && \ - echo 'APT::Default-Release "stable";' \ - > /etc/apt/apt.conf.d/99defaultrelease && \ - apt-get update && \ - # pull only the two runtime libs from testing +# bb requires a recent glibcxx version. +# Freeze Debian testing to a known-good snapshot so upstream testing changes +# do not break image builds. +RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${DEBIAN_TESTING_SNAPSHOT} testing main" \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive \ apt-get install -y -t testing libc6 libstdc++6 jq @@ -140,6 +143,8 @@ RUN cp /build/target/$([ "$RELEASE" = "1" ] && echo "release" || echo "debug")/b # Runtime stage dedicated to barretenberg-api-server FROM debian:bookworm-slim as runtime +ARG DEBIAN_TESTING_SNAPSHOT + ENV ROOT_DIR /polybase WORKDIR $ROOT_DIR @@ -158,14 +163,12 @@ RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20 mv bb /usr/local/bin/bb && \ rm barretenberg.tar.gz -# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) -# also installs jq, some bb commands require jq -RUN echo 'deb http://deb.debian.org/debian testing main' \ - > /etc/apt/sources.list.d/testing.list && \ - echo 'APT::Default-Release "stable";' \ - > /etc/apt/apt.conf.d/99defaultrelease && \ - apt-get update && \ - # pull only the two runtime libs from testing +# Freeze Debian testing to the same snapshot used in the tester stage. +RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${DEBIAN_TESTING_SNAPSHOT} testing main" \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive \ apt-get install -y -t testing libc6 libstdc++6 jq diff --git a/docker/Dockerfile.guild b/docker/Dockerfile.guild index d972c00..9092638 100644 --- a/docker/Dockerfile.guild +++ b/docker/Dockerfile.guild @@ -1,3 +1,5 @@ +ARG DEBIAN_TESTING_SNAPSHOT=20260404T140000Z + FROM rust:1-bookworm AS builder ARG RUST_GIT_FETCH_CLI ARG SCCACHE_GCS_BUCKET @@ -55,6 +57,8 @@ RUN if [ -f /gcs_key.json ]; then \ FROM debian:bookworm-slim +ARG DEBIAN_TESTING_SNAPSHOT + #Add custom user RUN adduser --disabled-password --gecos "" --uid 1001 polybase @@ -67,14 +71,13 @@ RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20 mv bb /usr/local/bin/bb && \ rm barretenberg.tar.gz -# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) -# also installs jq, some bb commands require jq -RUN echo 'deb http://deb.debian.org/debian testing main' \ - > /etc/apt/sources.list.d/testing.list && \ - echo 'APT::Default-Release "stable";' \ - > /etc/apt/apt.conf.d/99defaultrelease && \ - apt-get update && \ - # pull only the two runtime libs from testing +# Freeze Debian testing to a known-good snapshot so upstream testing changes +# do not break image builds. +RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${DEBIAN_TESTING_SNAPSHOT} testing main" \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive \ apt-get install -y -t testing libc6 libstdc++6 jq diff --git a/docker/Dockerfile.merge-cli b/docker/Dockerfile.merge-cli index f67bede..89bbb41 100644 --- a/docker/Dockerfile.merge-cli +++ b/docker/Dockerfile.merge-cli @@ -1,3 +1,5 @@ +ARG DEBIAN_TESTING_SNAPSHOT=20260404T140000Z + FROM rust:1-bookworm AS builder ARG RUST_GIT_FETCH_CLI ARG SCCACHE_GCS_BUCKET @@ -50,6 +52,8 @@ RUN if [ -f /gcs_key.json ]; then \ FROM debian:bookworm-slim +ARG DEBIAN_TESTING_SNAPSHOT + RUN apt-get update && apt-get install -y openssl ca-certificates libpq-dev postgresql wget tar curl # Download and install barretenberg @@ -59,14 +63,13 @@ RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20 mv bb /usr/local/bin/bb && \ rm barretenberg.tar.gz -# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) -# also installs jq, some bb commands require jq -RUN echo 'deb http://deb.debian.org/debian testing main' \ - > /etc/apt/sources.list.d/testing.list && \ - echo 'APT::Default-Release "stable";' \ - > /etc/apt/apt.conf.d/99defaultrelease && \ - apt-get update && \ - # pull only the two runtime libs from testing +# Freeze Debian testing to a known-good snapshot so upstream testing changes +# do not break image builds. +RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${DEBIAN_TESTING_SNAPSHOT} testing main" \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive \ apt-get install -y -t testing libc6 libstdc++6 jq @@ -87,4 +90,4 @@ ENV NODE_URL=http://localhost:8091/v0 ENV BURN_EVM_ADDR=0x9A4ebe49A963D3BC5f16639A0ABFF093CA0b040D ENV BATCH=10 -CMD merge-cli merge-ramps --batch ${BATCH} --burn-evm-address ${BURN_EVM_ADDR} +CMD ["sh", "-c", "exec merge-cli merge-ramps --batch ${BATCH} --burn-evm-address ${BURN_EVM_ADDR}"] diff --git a/docker/Dockerfile.node b/docker/Dockerfile.node index e5a446d..e3c4048 100644 --- a/docker/Dockerfile.node +++ b/docker/Dockerfile.node @@ -1,4 +1,6 @@ # Build binary +ARG DEBIAN_TESTING_SNAPSHOT=20260404T140000Z + FROM rust:1-bookworm AS workspace ARG SCCACHE_GCS_BUCKET @@ -37,6 +39,8 @@ WORKDIR /build FROM workspace AS tester +ARG DEBIAN_TESTING_SNAPSHOT + SHELL ["/bin/bash", "--login", "-c"] RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash @@ -63,15 +67,14 @@ RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20 mv bb /usr/local/bin/bb && \ rm barretenberg.tar.gz -# bb requires a recent glibcxx version -# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) -# also installs jq, some bb commands require jq -RUN echo 'deb http://deb.debian.org/debian testing main' \ - > /etc/apt/sources.list.d/testing.list && \ - echo 'APT::Default-Release "stable";' \ - > /etc/apt/apt.conf.d/99defaultrelease && \ - apt-get update && \ - # pull only the two runtime libs from testing +# bb requires a recent glibcxx version. +# Freeze Debian testing to a known-good snapshot so upstream testing changes +# do not break Docker builds on main. +RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${DEBIAN_TESTING_SNAPSHOT} testing main" \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive \ apt-get install -y -t testing libc6 libstdc++6 jq @@ -151,6 +154,8 @@ RUN cp /build/target/$([ "$RELEASE" = "1" ] && echo "release" || echo "debug")/n # Runtime stage - can be used for both node and prover mode FROM debian:bookworm-slim as runtime +ARG DEBIAN_TESTING_SNAPSHOT + ENV ROOT_DIR /polybase WORKDIR $ROOT_DIR @@ -169,14 +174,12 @@ RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20 mv bb /usr/local/bin/bb && \ rm barretenberg.tar.gz -# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) -# also installs jq, some bb commands require jq -RUN echo 'deb http://deb.debian.org/debian testing main' \ - > /etc/apt/sources.list.d/testing.list && \ - echo 'APT::Default-Release "stable";' \ - > /etc/apt/apt.conf.d/99defaultrelease && \ - apt-get update && \ - # pull only the two runtime libs from testing +# Freeze Debian testing to the same snapshot used in the tester stage. +RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${DEBIAN_TESTING_SNAPSHOT} testing main" \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive \ apt-get install -y -t testing libc6 libstdc++6 jq diff --git a/docker/Dockerfile.payy-evm b/docker/Dockerfile.payy-evm index ed0bb37..32db404 100644 --- a/docker/Dockerfile.payy-evm +++ b/docker/Dockerfile.payy-evm @@ -1,3 +1,5 @@ +ARG DEBIAN_TESTING_SNAPSHOT=20260404T140000Z + FROM rust:1-bookworm AS builder ARG SCCACHE_GCS_BUCKET @@ -55,6 +57,8 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ FROM debian:bookworm-slim AS runtime +ARG DEBIAN_TESTING_SNAPSHOT + ENV ROOT_DIR=/data ENV HOME=/data WORKDIR $ROOT_DIR @@ -73,14 +77,13 @@ RUN wget https://storage.googleapis.com/payy-public-fixtures/bb/v3.0.0-manual.20 mv bb /usr/local/bin/bb && \ rm barretenberg.tar.gz -# Enable backports and pull libstdc++ 13.x (exports GLIBCXX_3.4.31) -# also installs jq, some bb commands require jq -RUN echo 'deb http://deb.debian.org/debian testing main' \ - > /etc/apt/sources.list.d/testing.list && \ - echo 'APT::Default-Release "stable";' \ - > /etc/apt/apt.conf.d/99defaultrelease && \ - apt-get update && \ - # pull only the two runtime libs from testing +# Freeze Debian testing to a known-good snapshot so upstream testing changes +# do not break image builds. +RUN echo "deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/${DEBIAN_TESTING_SNAPSHOT} testing main" \ + > /etc/apt/sources.list.d/testing.list && \ + echo 'APT::Default-Release "stable";' \ + > /etc/apt/apt.conf.d/99defaultrelease && \ + apt-get update && \ DEBIAN_FRONTEND=noninteractive \ apt-get install -y -t testing libc6 libstdc++6 jq diff --git a/pkg/beam-cli/Cargo.toml b/pkg/beam-cli/Cargo.toml index 5a099b5..3ef062c 100644 --- a/pkg/beam-cli/Cargo.toml +++ b/pkg/beam-cli/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] argon2 = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } clap = { workspace = true } contextful = { workspace = true } contracts = { workspace = true } diff --git a/pkg/beam-cli/README.md b/pkg/beam-cli/README.md index 26cee80..7886456 100644 --- a/pkg/beam-cli/README.md +++ b/pkg/beam-cli/README.md @@ -113,6 +113,77 @@ the session. Write commands stop waiting automatically and return a `dropped` state if the active RPC stops reporting the submitted transaction for roughly 60 seconds. +## Fetch + +`beam fetch` is a built-in HTTP client for curl-style requests that can also satisfy x402 and +MPP payment challenges with your active Beam wallet. It makes the initial request directly from +Rust, prints the response body to stdout by default, and retries automatically after a successful +payment when the server answers with `402 Payment Required`. + +Supported request flags: + +- `-X, --method ` to override the HTTP method. Without `-X`, Beam defaults to `GET`, + or `POST` when `-d`, `--data`, or `--data-file` is present. +- `-H, --header ` to attach repeatable request headers. +- `-d, --data ` or `--data-file ` to send a request body. +- `-o, --output ` to write the response body to a file instead of stdout. +- `-v, --verbose` to print request and response headers on stderr. Beam redacts + sensitive request header values such as `Authorization`, `Cookie`, and payment + credentials before printing them. +- `-L, --follow-redirects` with `--max-redirects ` to follow redirects on the same + origin only. Beam stops before a cross-origin hop so origin-scoped headers are not replayed + to another host. +- `--connect-timeout ` and `--timeout ` for request timing. +- `--no-pay` to print the payment challenge and exit without signing. +- `--max-fee ` to auto-confirm only when the payment stays within that bound before + signing. Beam also rejects payments whose estimated gas alone exceeds the cap; native-asset + payments include the transfer amount plus estimated gas. +- `--allowed-chains [,...]` to auto-approve only those destination chains for + payment requests. If a request targets a different chain, Beam fails instead of prompting. +- `--dev` to allow plain HTTP payment challenges only for localhost or loopback development + fixtures. Beam otherwise refuses to pay a `402 Payment Required` response unless the challenged + URL is `https://`. + +Payment flow notes: + +- Use `--from ` to choose which stored wallet pays for the request. +- Use `--chain ` to force x402 offer selection. For MPP, it acts as an explicit + constraint: if the challenge already includes a different `chainId`, Beam fails instead of + prompting on that network. +- `--chain` and `--allowed-chains` accept the same selectors as other Beam chain commands, + including canonical names, numeric ids, and aliases like `eth`, `bsc`, `arb`, or `payydev`. +- MPP challenges that omit a chain are rejected unless you explicitly provide `--chain` or + `--rpc`. +- MPP problem responses must include a valid `WWW-Authenticate: Payment ...` challenge. Beam + rejects malformed MPP responses on both the paid and `--no-pay` paths. +- When a payment request targets a different chain than your selected/default chain, Beam prompts + for confirmation unless `--allowed-chains` explicitly permits it. +- Payment challenges served over plain HTTP are rejected unless you opt into `--dev` and the + challenged URL stays on `localhost` or a loopback address. +- x402 responses are retried with a Beam-generated payment proof header after the payment + transaction confirms. +- MPP challenges are retried with an `Authorization: Payment ...` credential after the payment + transaction confirms. If the original same-origin request already set `Authorization`, Beam + fails instead of overwriting the caller-supplied credential. +- If a same-origin redirect rewrites the request before the `402 Payment Required` response + (for example `POST` becoming `GET` on `302`/`303`), Beam retries that effective challenged + request after payment instead of replaying the pre-redirect method and body. +- In the REPL, once `beam fetch` starts the on-chain payment transaction, `Ctrl-C` stops waiting + for confirmation without losing the submitted transaction hash. After that transaction phase, + `Ctrl-C` again cancels the paid retry request or response download and returns to the prompt. + +Examples: + +```bash +beam fetch https://api.example.com/data +beam fetch -X POST -H "Content-Type: application/json" -d '{"key":"value"}' https://api.example.com/submit +beam fetch --max-fee 0.001 https://paywall.example.com/article/123 +beam fetch --allowed-chains base,8453 https://paywall.example.com/article/123 +beam fetch --no-pay https://paywall.example.com/article/123 +beam --from alice --chain base fetch --max-fee 0.001 https://paywall.example.com/article/123 +beam fetch -v -L https://api.example.com/redirect +``` + ## Wallets Wallets are stored in an encrypted local keystore at `~/.beam/wallets.json`. @@ -322,13 +393,14 @@ beam erc20 transfer beam erc20 approve beam call [args...] beam send [--value ] [args...] +beam fetch [request-flags] beam update ``` Useful examples: ```bash -beam --output json balance +beam --format json balance beam --from alice balance USDC beam tokens beam --chain base tokens add 0xTokenAddress @@ -455,12 +527,12 @@ session RPC override so the prompt and subsequent commands stay on the selected `beam` also supports structured output modes for scripting: -- `--output default` -- `--output json` -- `--output yaml` -- `--output markdown` -- `--output compact` -- `--output quiet` +- `--format default` +- `--format json` +- `--format yaml` +- `--format markdown` +- `--format compact` +- `--format quiet` Human-facing warnings, errors, and the interactive prompt use color automatically when beam is writing to a terminal. Override that behavior with `--color auto`, `--color always`, or diff --git a/pkg/beam-cli/src/cli.rs b/pkg/beam-cli/src/cli.rs index 482eef6..0869db3 100644 --- a/pkg/beam-cli/src/cli.rs +++ b/pkg/beam-cli/src/cli.rs @@ -1,9 +1,15 @@ -// lint-long-file-override allow-max-lines=280 +// lint-long-file-override allow-max-lines=300 +mod fetch; +mod normalize; + pub mod util; use clap::{Args, Parser, Subcommand}; use crate::{display::ColorMode, output::OutputMode, runtime::InvocationOverrides}; + +pub use fetch::FetchArgs; +pub(crate) use normalize::normalize_cli_args; use util::UtilAction; #[derive(Debug, Parser)] @@ -21,7 +27,7 @@ pub struct Cli { #[arg(long, global = true)] pub chain: Option, - #[arg(long, global = true, value_enum, default_value_t = OutputMode::Default)] + #[arg(long = "format", global = true, value_enum, default_value_t = OutputMode::Default)] pub output: OutputMode, #[arg( @@ -84,6 +90,8 @@ pub enum Command { Call(CallArgs), /// Send a contract transaction Send(SendArgs), + /// Fetch an HTTP resource and handle x402 / MPP payment challenges + Fetch(FetchArgs), /// Check for beam updates Update, #[command(name = "__refresh-update-status", hide = true)] diff --git a/pkg/beam-cli/src/cli/fetch.rs b/pkg/beam-cli/src/cli/fetch.rs new file mode 100644 index 0000000..9cfa58c --- /dev/null +++ b/pkg/beam-cli/src/cli/fetch.rs @@ -0,0 +1,52 @@ +use clap::Args; + +#[derive(Clone, Debug, Args)] +pub struct FetchArgs { + pub url: String, + + #[arg(short = 'X', long)] + pub method: Option, + + #[arg(short = 'H', long = "header")] + pub headers: Vec, + + #[arg(short = 'd', long, conflicts_with = "data_file")] + pub data: Option, + + #[arg(long, conflicts_with = "data")] + pub data_file: Option, + + #[arg(id = "fetch-output", short = 'o', long = "output")] + pub output_path: Option, + + #[arg(short = 'v', long, default_value_t = false)] + pub verbose: bool, + + #[arg(short = 'L', long, default_value_t = false)] + pub follow_redirects: bool, + + #[arg(long, default_value_t = 10)] + pub max_redirects: usize, + + #[arg(long)] + pub connect_timeout: Option, + + #[arg(long)] + pub timeout: Option, + + #[arg(long)] + pub max_fee: Option, + + #[arg(long, value_delimiter = ',', value_name = "NAME|ID")] + pub allowed_chains: Vec, + + #[arg(long, default_value_t = false)] + pub no_pay: bool, + + #[arg( + long, + default_value_t = false, + help = "Allow loopback HTTP payment challenges for development and fixtures" + )] + pub dev: bool, +} diff --git a/pkg/beam-cli/src/cli/normalize.rs b/pkg/beam-cli/src/cli/normalize.rs new file mode 100644 index 0000000..8e39b5d --- /dev/null +++ b/pkg/beam-cli/src/cli/normalize.rs @@ -0,0 +1,110 @@ +use std::ffi::OsString; + +pub(crate) fn normalize_cli_args(args: I) -> Vec +where + I: IntoIterator, + S: Into, +{ + let mut normalized = Vec::new(); + let mut command = None::; + let mut consume_flag_value = false; + + for (index, arg) in args.into_iter().enumerate() { + let arg = arg.into(); + let Some(value) = arg.to_str().map(ToString::to_string) else { + normalized.push(arg); + continue; + }; + + if index == 0 { + normalized.push(arg); + continue; + } + + if consume_flag_value { + normalized.push(arg); + consume_flag_value = false; + continue; + } + + if command.is_none() { + if let Some(rewritten) = rewrite_output_flag(&value, false) { + normalized.push(rewritten.into()); + if !value.contains('=') { + consume_flag_value = true; + } + continue; + } + + if expects_global_flag_value(&value) { + normalized.push(arg); + consume_flag_value = !value.contains('='); + continue; + } + + if is_command_token(&value) { + command = Some(value); + normalized.push(arg); + continue; + } + + normalized.push(arg); + continue; + } + + if let Some(rewritten) = rewrite_output_flag(&value, command.as_deref() == Some("fetch")) { + normalized.push(rewritten.into()); + if !value.contains('=') { + consume_flag_value = true; + } + continue; + } + + normalized.push(arg); + } + + normalized +} + +fn rewrite_output_flag(value: &str, is_fetch_command: bool) -> Option { + if is_fetch_command { + return None; + } + + if value == "--output" { + return Some("--format".to_string()); + } + + value + .strip_prefix("--output=") + .map(|suffix| format!("--format={suffix}")) +} + +fn expects_global_flag_value(value: &str) -> bool { + matches!( + value.split_once('=').map_or(value, |(flag, _)| flag), + "--chain" | "--color" | "--format" | "--from" | "--rpc" + ) +} + +fn is_command_token(value: &str) -> bool { + matches!( + value, + "wallets" + | "util" + | "chains" + | "rpc" + | "tokens" + | "balance" + | "transfer" + | "txn" + | "tx" + | "block" + | "erc20" + | "call" + | "send" + | "fetch" + | "update" + | "__refresh-update-status" + ) +} diff --git a/pkg/beam-cli/src/commands/fetch.rs b/pkg/beam-cli/src/commands/fetch.rs new file mode 100644 index 0000000..55e8fb1 --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch.rs @@ -0,0 +1,600 @@ +// lint-long-file-override allow-max-lines=600 +mod logging; +pub(crate) mod payment; +pub(crate) mod protocol; +mod retry; + +use std::{net::IpAddr, path::PathBuf, time::Duration}; + +use contextful::ResultContextExt; +use futures::stream; +use reqwest::{ + Body, Client, Method, RequestBuilder, Response, StatusCode, Url, + header::{AUTHORIZATION, CONTENT_LENGTH, HeaderMap, HeaderName, HeaderValue}, + redirect::Policy, +}; +use tokio::{ + fs::File, + io::{AsyncReadExt, AsyncWrite, AsyncWriteExt}, +}; + +use crate::{ + cli::FetchArgs, + error::{Error, Result}, + output::OutputMode, + runtime::BeamApp, +}; + +use self::{ + super::interactive_interrupt::delegate_current_interrupt_to_command, + logging::{print_request, print_response}, + payment::{approve_payment, execute_payment, prepare_mpp_payment, prepare_x402_payment}, + protocol::{PaymentChallenge, RetryHeader, parse_payment_challenge}, + retry::{RedirectTracker, origin_locked_redirect_policy, retry_spec_for_challenge}, +}; + +const USER_AGENT: &str = concat!("beam/", env!("CARGO_PKG_VERSION")); + +#[cfg(test)] +pub(crate) use self::logging::printable_request_header_value_for_test; + +#[derive(Clone, Debug)] +struct RequestSpec { + body: Option, + headers: HeaderMap, + method: Method, + url: Url, +} + +struct FetchClient { + client: Client, + redirect_tracker: RedirectTracker, +} + +struct SentRequest { + effective_spec: RequestSpec, + response: Response, +} + +#[cfg(test)] +#[derive(Clone, Debug)] +pub(crate) struct RequestSpecForTest { + pub(crate) body: Option>, + pub(crate) headers: HeaderMap, + pub(crate) method: Method, + pub(crate) url: Url, +} + +#[cfg(test)] +#[derive(Clone, Debug)] +pub(crate) struct SentRequestForTest { + pub(crate) effective_spec: RequestSpecForTest, + pub(crate) status: StatusCode, +} + +#[derive(Clone, Debug)] +enum RequestBodySpec { + Inline(Vec), + File { length: u64, path: PathBuf }, +} + +pub async fn run(app: &BeamApp, args: FetchArgs) -> Result<()> { + let spec = request_spec_from_args(&args).await?; + let client = build_initial_request_client(&args, &spec.url)?; + let sent = send_request(&client, &spec, None, args.verbose).await?; + + if sent.response.status() != StatusCode::PAYMENT_REQUIRED { + return write_response(sent.response, args.output_path.as_deref(), app.output_mode).await; + } + + handle_payment_required(app, &args, &client, &sent.effective_spec, sent.response).await +} + +async fn handle_payment_required( + app: &BeamApp, + args: &FetchArgs, + client: &FetchClient, + spec: &RequestSpec, + response: Response, +) -> Result<()> { + let challenged_url = response.url().clone(); + ensure_payment_challenge_transport(args, &challenged_url)?; + let headers = response.headers().clone(); + let body = response + .bytes() + .await + .map_err(|_| Error::FetchRequestFailed)?; + let challenge = + parse_payment_challenge(&headers, &body)?.ok_or(Error::FetchInvalidPaymentResponse)?; + + if args.no_pay { + eprintln!("{}", challenge.describe()); + return Err(Error::FetchPaymentRequired); + } + + let retry_spec = retry_spec_for_challenge(spec, challenged_url); + if matches!(challenge, PaymentChallenge::Mpp(_)) { + ensure_retry_header_can_merge(&retry_spec.headers, &AUTHORIZATION)?; + } + + let payment = match &challenge { + PaymentChallenge::X402(x402) => prepare_x402_payment(app, args, x402).await?, + PaymentChallenge::Mpp(mpp) => prepare_mpp_payment(app, mpp).await?, + }; + + eprintln!( + "{}", + payment.confirmation_message(challenge.protocol_name()) + ); + let chain_store = app.chain_store.get().await; + approve_payment(args, &payment, &chain_store)?; + // Interactive fetch starts under the REPL ctrl-c wrapper, but the payment transaction owns + // ctrl-c while it is being submitted so Beam can surface the tx hash/status. Once execution + // returns, the paid retry/download path falls back to REPL interrupt handling. + let executed = { + let _interrupt_guard = delegate_current_interrupt_to_command(); + execute_payment(app, &payment).await? + }; + let retry_header = challenge.retry_header(&executed)?; + let retry_response = + send_retry_request(args, client, &retry_spec, &retry_header, args.verbose).await?; + if retry_response.status() == StatusCode::PAYMENT_REQUIRED { + let retry_headers = retry_response.headers().clone(); + let retry_body = retry_response + .bytes() + .await + .map_err(|_| Error::FetchRequestFailed)?; + + if let Some(retry_challenge) = parse_payment_challenge(&retry_headers, &retry_body)? { + eprintln!("{}", retry_challenge.describe()); + } + + return Err(Error::FetchPaymentRequired); + } + + write_response(retry_response, args.output_path.as_deref(), app.output_mode).await +} + +fn ensure_payment_challenge_transport(args: &FetchArgs, challenged_url: &Url) -> Result<()> { + if challenged_url.scheme() == "https" { + return Ok(()); + } + + if args.dev && challenged_url.scheme() == "http" && is_loopback_http_host(challenged_url) { + return Ok(()); + } + + Err(Error::FetchPaymentRequiresHttps { + url: challenged_url.to_string(), + }) +} + +fn is_loopback_http_host(url: &Url) -> bool { + let Some(host) = url.host_str() else { + return false; + }; + + if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") { + return true; + } + + host.parse::().is_ok_and(|ip| ip.is_loopback()) +} + +#[cfg(test)] +pub(crate) fn ensure_payment_challenge_transport_for_test( + args: &FetchArgs, + challenged_url: &Url, +) -> Result<()> { + ensure_payment_challenge_transport(args, challenged_url) +} + +async fn request_spec_from_args(args: &FetchArgs) -> Result { + Ok(RequestSpec { + body: request_body(args).await?, + headers: request_headers(&args.headers)?, + method: request_method(args)?, + url: Url::parse(&args.url).map_err(|_| Error::FetchRequestFailed)?, + }) +} + +async fn request_body(args: &FetchArgs) -> Result> { + if let Some(data) = args.data.as_ref() { + return Ok(Some(RequestBodySpec::Inline(data.as_bytes().to_vec()))); + } + + match args.data_file.as_ref() { + Some(path) => { + let path = PathBuf::from(path); + let length = tokio::fs::metadata(&path) + .await + .context("stat beam fetch data file")? + .len(); + + Ok(Some(RequestBodySpec::File { length, path })) + } + None => Ok(None), + } +} + +fn request_headers(values: &[String]) -> Result { + let mut headers = HeaderMap::new(); + + for value in values { + let (name, value) = value + .split_once(':') + .ok_or_else(|| Error::FetchInvalidHeader { + value: value.clone(), + })?; + let name = HeaderName::from_bytes(name.trim().as_bytes()).map_err(|_| { + Error::FetchInvalidHeader { + value: value.to_string(), + } + })?; + let value = HeaderValue::from_str(value.trim()).map_err(|_| Error::FetchInvalidHeader { + value: value.to_string(), + })?; + headers.append(name, value); + } + + Ok(headers) +} + +fn parse_method(value: &str) -> Result { + Method::from_bytes(value.trim().as_bytes()).map_err(|_| Error::FetchInvalidMethod { + value: value.to_string(), + }) +} + +fn request_method(args: &FetchArgs) -> Result { + match args.method.as_deref() { + Some(method) => parse_method(method), + None if args.data.is_some() || args.data_file.is_some() => Ok(Method::POST), + None => Ok(Method::GET), + } +} + +fn build_initial_request_client(args: &FetchArgs, request_url: &Url) -> Result { + let redirect_tracker = RedirectTracker::default(); + let policy = redirect_policy( + args.follow_redirects, + args.max_redirects, + request_url, + redirect_tracker.clone(), + ); + + build_client_with_policy(args, policy, redirect_tracker) +} + +#[cfg(test)] +pub(crate) fn build_initial_request_client_for_test( + args: &FetchArgs, + request_url: &Url, +) -> Result { + Ok(build_initial_request_client(args, request_url)? + .client + .clone()) +} + +fn build_client_with_policy( + args: &FetchArgs, + policy: Policy, + redirect_tracker: RedirectTracker, +) -> Result { + let mut builder = Client::builder().user_agent(USER_AGENT); + + builder = builder.redirect(policy); + + if let Some(seconds) = args.connect_timeout { + builder = builder.connect_timeout(Duration::from_secs(seconds)); + } + + if let Some(seconds) = args.timeout { + builder = builder.timeout(Duration::from_secs(seconds)); + } + + let client = builder.build().map_err(|_| Error::FetchRequestFailed)?; + + Ok(FetchClient { + client, + redirect_tracker, + }) +} + +fn redirect_policy( + follow_redirects: bool, + max_redirects: usize, + request_url: &Url, + redirect_tracker: RedirectTracker, +) -> Policy { + if follow_redirects { + origin_locked_redirect_policy(max_redirects, request_url, redirect_tracker) + } else { + Policy::none() + } +} + +fn build_payment_retry_client(args: &FetchArgs, original_url: &Url) -> Result { + let redirect_tracker = RedirectTracker::default(); + + build_client_with_policy( + args, + origin_locked_redirect_policy(args.max_redirects, original_url, redirect_tracker.clone()), + redirect_tracker, + ) +} + +#[cfg(test)] +pub(crate) fn build_payment_retry_client_for_test( + args: &FetchArgs, + original_url: &Url, +) -> Result { + Ok(build_payment_retry_client(args, original_url)? + .client + .clone()) +} + +async fn send_retry_request( + args: &FetchArgs, + client: &FetchClient, + spec: &RequestSpec, + retry_header: &RetryHeader, + verbose: bool, +) -> Result { + if !args.follow_redirects { + return Ok(send_request(client, spec, Some(retry_header), verbose) + .await? + .response); + } + + let retry_client = build_payment_retry_client(args, &spec.url)?; + Ok( + send_request(&retry_client, spec, Some(retry_header), verbose) + .await? + .response, + ) +} + +#[cfg(test)] +pub(crate) async fn send_retry_request_for_test( + args: &FetchArgs, + request_url: &Url, + challenged_url: &Url, + retry_header: RetryHeader, +) -> Result { + send_retry_request_with_spec_for_test( + args, + request_url, + challenged_url, + Method::GET, + HeaderMap::new(), + None, + retry_header, + ) + .await +} + +#[cfg(test)] +pub(crate) async fn send_retry_request_with_spec_for_test( + args: &FetchArgs, + request_url: &Url, + challenged_url: &Url, + method: Method, + headers: HeaderMap, + body: Option>, + retry_header: RetryHeader, +) -> Result { + let client = build_initial_request_client(args, request_url)?; + let spec = RequestSpec { + body: body.map(RequestBodySpec::Inline), + headers, + method, + url: request_url.clone(), + }; + let retry_spec = retry_spec_for_challenge(&spec, challenged_url.clone()); + + send_retry_request(args, &client, &retry_spec, &retry_header, false).await +} + +#[cfg(test)] +pub(crate) async fn send_request_for_test( + args: &FetchArgs, + request_url: &Url, + method: Method, + headers: HeaderMap, + body: Option>, +) -> Result { + let client = build_initial_request_client(args, request_url)?; + let spec = RequestSpec { + body: body.map(RequestBodySpec::Inline), + headers, + method, + url: request_url.clone(), + }; + let sent = send_request(&client, &spec, None, false).await?; + + Ok(SentRequestForTest { + effective_spec: RequestSpecForTest::from_request_spec(&sent.effective_spec), + status: sent.response.status(), + }) +} + +async fn send_request( + client: &FetchClient, + spec: &RequestSpec, + retry_header: Option<&RetryHeader>, + verbose: bool, +) -> Result { + let headers = merged_headers(&spec.headers, retry_header, spec.body.as_ref())?; + + if verbose { + print_request(spec, &headers); + } + + client.redirect_tracker.clear(); + + let request = client + .client + .request(spec.method.clone(), spec.url.clone()) + .headers(headers); + + let request = match spec.body.as_ref() { + Some(body) => body.apply(request).await?, + None => request, + }; + + let response = request + .send() + .await + .map_err(|_| Error::FetchRequestFailed)?; + + if verbose { + print_response(&response); + } + + Ok(SentRequest { + effective_spec: client.redirect_tracker.effective_request_spec(spec), + response, + }) +} + +fn merged_headers( + base: &HeaderMap, + retry_header: Option<&RetryHeader>, + body: Option<&RequestBodySpec>, +) -> Result { + let mut headers = base.clone(); + + if let Some(retry_header) = retry_header { + ensure_retry_header_can_merge(&headers, &retry_header.name)?; + headers.insert(retry_header.name.clone(), retry_header.value.clone()); + if retry_header.name == AUTHORIZATION { + headers.remove("payment-signature"); + headers.remove("x-payment"); + } + } + + if let Some(body) = body { + body.apply_headers(&mut headers)?; + } + + Ok(headers) +} + +fn ensure_retry_header_can_merge(base: &HeaderMap, retry_header_name: &HeaderName) -> Result<()> { + if retry_header_name == AUTHORIZATION && base.contains_key(AUTHORIZATION) { + return Err(Error::FetchPaymentAuthorizationConflict); + } + + Ok(()) +} + +async fn write_response( + response: Response, + output_path: Option<&str>, + output_mode: OutputMode, +) -> Result<()> { + if let Some(output_path) = output_path { + let mut output = File::create(output_path) + .await + .context("create beam fetch output file")?; + write_response_body( + response, + &mut output, + "write beam fetch output file", + "flush beam fetch output file", + ) + .await?; + return Ok(()); + } + + if output_mode == OutputMode::Quiet { + return Ok(()); + } + + let mut stdout = tokio::io::stdout(); + write_response_body( + response, + &mut stdout, + "write beam fetch stdout", + "flush beam fetch stdout", + ) + .await +} + +impl RequestBodySpec { + fn apply_headers(&self, headers: &mut HeaderMap) -> Result<()> { + let Self::File { length, .. } = self else { + return Ok(()); + }; + + if headers.contains_key(CONTENT_LENGTH) { + return Ok(()); + } + + let value = + HeaderValue::from_str(&length.to_string()).map_err(|_| Error::FetchRequestFailed)?; + headers.insert(CONTENT_LENGTH, value); + Ok(()) + } + + async fn apply(&self, request: RequestBuilder) -> Result { + match self { + Self::Inline(body) => Ok(request.body(body.clone())), + Self::File { path, .. } => { + let file = File::open(path) + .await + .context("open beam fetch data file")?; + Ok(request.body(Body::wrap_stream(request_body_stream(file)))) + } + } + } +} + +#[cfg(test)] +impl RequestSpecForTest { + fn from_request_spec(spec: &RequestSpec) -> Self { + Self { + body: match spec.body.as_ref() { + Some(RequestBodySpec::Inline(body)) => Some(body.clone()), + Some(RequestBodySpec::File { .. }) | None => None, + }, + headers: spec.headers.clone(), + method: spec.method.clone(), + url: spec.url.clone(), + } + } +} + +fn request_body_stream(file: File) -> impl futures::Stream>> { + const CHUNK_SIZE: usize = 16 * 1024; + + stream::try_unfold(file, |mut file| async move { + let mut chunk = vec![0; CHUNK_SIZE]; + let read = file.read(&mut chunk).await?; + if read == 0 { + return Ok(None); + } + + chunk.truncate(read); + Ok(Some((chunk, file))) + }) +} + +async fn write_response_body( + mut response: Response, + writer: &mut (dyn AsyncWrite + Unpin), + write_context: &'static str, + flush_context: &'static str, +) -> Result<()> { + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| Error::FetchRequestFailed)? + { + writer.write_all(&chunk).await.context(write_context)?; + } + + writer.flush().await.context(flush_context)?; + Ok(()) +} diff --git a/pkg/beam-cli/src/commands/fetch/logging.rs b/pkg/beam-cli/src/commands/fetch/logging.rs new file mode 100644 index 0000000..75cb003 --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/logging.rs @@ -0,0 +1,63 @@ +use reqwest::{ + Response, + header::{HeaderMap, HeaderName, HeaderValue}, +}; + +use super::{ + RequestSpec, + protocol::{PAYMENT_SIGNATURE_HEADER, X_PAYMENT_HEADER}, +}; + +const REDACTED_REQUEST_HEADER_VALUE: &str = ""; + +pub(super) fn print_request(spec: &RequestSpec, headers: &HeaderMap) { + eprintln!("> {} {}", spec.method, spec.url); + for (name, value) in headers { + eprintln!( + "> {}: {}", + name.as_str(), + printable_request_header_value(name, value) + ); + } +} + +pub(super) fn print_response(response: &Response) { + let reason = response.status().canonical_reason().unwrap_or("Unknown"); + eprintln!("< {} {}", response.status().as_u16(), reason); + for (name, value) in response.headers() { + eprintln!("< {}: {}", name.as_str(), printable_header_value(value)); + } +} + +fn printable_header_value(value: &HeaderValue) -> String { + value + .to_str() + .map_or_else(|_| "".to_string(), ToString::to_string) +} + +fn printable_request_header_value(name: &HeaderName, value: &HeaderValue) -> String { + if is_sensitive_request_header(name) { + return REDACTED_REQUEST_HEADER_VALUE.to_string(); + } + + printable_header_value(value) +} + +fn is_sensitive_request_header(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "authorization" + | "proxy-authorization" + | "cookie" + | PAYMENT_SIGNATURE_HEADER + | X_PAYMENT_HEADER + ) +} + +#[cfg(test)] +pub(crate) fn printable_request_header_value_for_test(name: &str, value: &str) -> String { + let name = HeaderName::from_bytes(name.as_bytes()).expect("test header name"); + let value = HeaderValue::from_str(value).expect("test header value"); + + printable_request_header_value(&name, &value) +} diff --git a/pkg/beam-cli/src/commands/fetch/payment.rs b/pkg/beam-cli/src/commands/fetch/payment.rs new file mode 100644 index 0000000..467da9a --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/payment.rs @@ -0,0 +1,254 @@ +// lint-long-file-override allow-max-lines=300 +mod approval; +mod chain_match; +mod prepare; +mod resolve; +mod selection; + +use contracts::{Address, Client, U256}; +use serde_json::{Value, json}; +use web3::ethabi::StateMutability; + +use crate::{ + abi::parse_function, + chains::BeamChains, + commands::signing::prompt_active_signer, + error::{Error, Result}, + evm::{ + FunctionCall, TransactionGas, format_units, parse_units, send_function_with_gas, + send_native_with_gas, + }, + human_output::sanitize_control_chars, + output::with_loading_handle, + runtime::BeamApp, + transaction::{TransactionExecution, loading_message}, +}; + +#[cfg(test)] +pub(crate) use self::approval::approve_payment_with; +pub(crate) use self::{ + approval::approve_payment, + prepare::{prepare_mpp_payment, prepare_x402_payment}, +}; + +#[derive(Clone, Debug)] +pub(crate) struct PreparedPayment { + pub accepted: Value, + pub amount: U256, + pub amount_display: String, + pub asset: PaymentAsset, + pub asset_id: String, + pub chain: PaymentChain, + pub client: Client, + pub description: Option, + pub gas: GasEstimate, + pub network: String, + pub payer: Address, + pub recipient: Address, + pub selected_chain: Option, + pub scheme: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct ExecutedPayment { + pub accepted: Value, + pub network: String, + pub proof: Value, + pub scheme: String, + pub source: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct PaymentChain { + pub aliases: Vec, + pub chain_id: u64, + pub display_name: String, + pub key: String, + pub native_symbol: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct GasEstimate { + pub fee: U256, + pub gas_limit: U256, + pub gas_price: U256, +} + +#[derive(Clone, Debug)] +pub(crate) struct PaymentAsset { + pub decimals: u8, + pub kind: PaymentAssetKind, + pub label: String, +} + +#[derive(Clone, Debug)] +pub(crate) enum PaymentAssetKind { + Erc20(Address), + Native, +} + +impl PaymentChain { + pub(crate) fn matches_selector(&self, selector: &str, chain_store: &BeamChains) -> bool { + chain_match::payment_chain_matches_selector(self, selector, chain_store) + } + + pub(crate) fn summary(&self) -> String { + format!("{} ({})", self.display_name, self.chain_id) + } +} + +pub(crate) async fn execute_payment( + app: &BeamApp, + payment: &PreparedPayment, +) -> Result { + let signer = prompt_active_signer(app).await?; + let action = format!( + "payment of {} {} to {:#x}", + payment.amount_display, payment.asset.label, payment.recipient + ); + let client = payment.client.clone(); + let recipient = payment.recipient; + let amount = payment.amount; + let gas = payment.transaction_gas(); + + let execution = with_loading_handle( + app.output_mode, + format!("Sending {action} and waiting for confirmation..."), + |loading| async move { + match payment.asset.kind.clone() { + PaymentAssetKind::Native => { + send_native_with_gas( + &client, + &signer, + recipient, + amount, + Some(gas), + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + } + PaymentAssetKind::Erc20(token) => { + let function = + parse_function("transfer(address,uint256)", StateMutability::NonPayable)?; + let args = vec![format!("{recipient:#x}"), amount.to_string()]; + + send_function_with_gas( + &client, + &signer, + FunctionCall { + args: &args, + contract: token, + function: &function, + value: U256::zero(), + }, + Some(gas), + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + } + } + }, + ) + .await?; + + let tx_hash = match execution { + TransactionExecution::Confirmed(outcome) => outcome.tx_hash, + TransactionExecution::Pending(pending) => { + return Err(Error::FetchPaymentUnconfirmed { + tx_hash: pending.tx_hash, + }); + } + TransactionExecution::Dropped(dropped) => { + return Err(Error::FetchPaymentUnconfirmed { + tx_hash: dropped.tx_hash, + }); + } + }; + + Ok(ExecutedPayment { + accepted: payment.accepted.clone(), + network: payment.network.clone(), + proof: json!({ + "amount": payment.amount.to_string(), + "asset": payment.asset_id, + "chainId": payment.chain.chain_id, + "from": format!("{:#x}", payment.payer), + "kind": "beam-evm-transfer", + "network": payment.network, + "to": format!("{:#x}", payment.recipient), + "txHash": tx_hash, + }), + scheme: payment.scheme.clone(), + source: Some(format!( + "did:pkh:eip155:{}:{:#x}", + payment.chain.chain_id, payment.payer + )), + }) +} + +impl PreparedPayment { + pub(crate) fn ensure_max_fee_allows(&self, max_fee: &str) -> Result<()> { + let gas_threshold = parse_units(max_fee, 18)?; + if self.gas.fee > gas_threshold { + return Err(Error::FetchPaymentExceedsMaxFee); + } + + match &self.asset.kind { + PaymentAssetKind::Native => { + if self.amount.saturating_add(self.gas.fee) > gas_threshold { + return Err(Error::FetchPaymentExceedsMaxFee); + } + } + PaymentAssetKind::Erc20(_) => { + let asset_threshold = parse_units(max_fee, usize::from(self.asset.decimals))?; + if self.amount > asset_threshold { + return Err(Error::FetchPaymentExceedsMaxFee); + } + } + } + + Ok(()) + } + + fn transaction_gas(&self) -> TransactionGas { + TransactionGas { + gas_limit: self.gas.gas_limit, + gas_price: self.gas.gas_price, + } + } + + pub(crate) fn confirmation_message(&self, protocol: &str) -> String { + let mut lines = vec![ + format!("Payment required via {protocol}"), + format!( + "Amount: {} {}", + self.amount_display, + sanitize_control_chars(&self.asset.label) + ), + format!("Recipient: {:#x}", self.recipient), + format!( + "Network: {} ({})", + sanitize_control_chars(&self.chain.display_name), + self.chain.chain_id + ), + format!( + "Estimated gas: {} {} (limit {}, price {})", + format_units(self.gas.fee, 18), + sanitize_control_chars(&self.chain.native_symbol), + self.gas.gas_limit, + self.gas.gas_price, + ), + ]; + + if let Some(description) = self.description.as_ref() { + lines.push(format!( + "Description: {}", + sanitize_control_chars(description) + )); + } + + lines.join("\n") + } +} diff --git a/pkg/beam-cli/src/commands/fetch/payment/approval.rs b/pkg/beam-cli/src/commands/fetch/payment/approval.rs new file mode 100644 index 0000000..5c841f5 --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/payment/approval.rs @@ -0,0 +1,127 @@ +use std::io::{BufRead, Write}; + +use contextful::ResultContextExt; + +use crate::{ + chains::BeamChains, + cli::FetchArgs, + error::{Error, Result}, + human_output::sanitize_control_chars, +}; + +use super::PreparedPayment; + +pub(crate) fn approve_payment( + args: &FetchArgs, + payment: &PreparedPayment, + chain_store: &BeamChains, +) -> Result<()> { + let stdin = std::io::stdin(); + let stderr = std::io::stderr(); + + approve_payment_with( + args, + payment, + chain_store, + &mut stdin.lock(), + &mut stderr.lock(), + ) +} + +pub(crate) fn approve_payment_with( + args: &FetchArgs, + payment: &PreparedPayment, + chain_store: &BeamChains, + input: &mut R, + output: &mut W, +) -> Result<()> +where + R: BufRead, + W: Write, +{ + ensure_payment_chain_allowed(args, payment, chain_store, input, output)?; + + if let Some(max_fee) = args.max_fee.as_ref() { + payment.ensure_max_fee_allows(max_fee)?; + return Ok(()); + } + + if confirm_with(input, output, "Pay now? [y/N]: ")? { + Ok(()) + } else { + Err(Error::FetchPaymentRejected) + } +} + +fn ensure_payment_chain_allowed( + args: &FetchArgs, + payment: &PreparedPayment, + chain_store: &BeamChains, + input: &mut R, + output: &mut W, +) -> Result<()> +where + R: BufRead, + W: Write, +{ + if !args.allowed_chains.is_empty() { + if args + .allowed_chains + .iter() + .any(|selector| payment.chain.matches_selector(selector, chain_store)) + { + return Ok(()); + } + + return Err(Error::FetchPaymentChainNotAllowed { + chain: payment.chain.summary(), + }); + } + + let Some(selected_chain) = payment.selected_chain.as_ref() else { + return Ok(()); + }; + if selected_chain.chain_id == payment.chain.chain_id { + return Ok(()); + } + + let prompt = format!( + "Accept payment request on {} instead of selected chain {}? [y/N]: ", + sanitize_control_chars(&payment.chain.summary()), + sanitize_control_chars(&selected_chain.summary()), + ); + + if confirm_with(input, output, &prompt)? { + Ok(()) + } else { + Err(Error::FetchPaymentRejected) + } +} + +fn confirm_with(input: &mut R, output: &mut W, prompt: &str) -> Result +where + R: BufRead, + W: Write, +{ + loop { + write!(output, "{prompt}").context("write beam fetch prompt")?; + output.flush().context("flush beam fetch prompt")?; + + let mut value = String::new(); + if input + .read_line(&mut value) + .context("read beam fetch prompt")? + == 0 + { + return Err(Error::PromptClosed { + label: "beam fetch payment".to_string(), + }); + } + + match value.trim().to_ascii_lowercase().as_str() { + "" | "n" | "no" => return Ok(false), + "y" | "yes" => return Ok(true), + _ => continue, + } + } +} diff --git a/pkg/beam-cli/src/commands/fetch/payment/chain_match.rs b/pkg/beam-cli/src/commands/fetch/payment/chain_match.rs new file mode 100644 index 0000000..8044864 --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/payment/chain_match.rs @@ -0,0 +1,133 @@ +use std::collections::BTreeSet; + +use crate::{ + chains::{BeamChains, ChainEntry, find_chain}, + commands::fetch::protocol::X402Offer, +}; + +use super::{PaymentChain, resolve::chain_id_from_network}; + +pub(super) fn payment_chain_matches_selector( + chain: &PaymentChain, + selector: &str, + chain_store: &BeamChains, +) -> bool { + selector_matches_chain( + selector, + chain_store, + Some(chain.chain_id), + Some(chain.key.as_str()), + Some(chain.display_name.as_str()), + chain.aliases.iter().map(String::as_str), + ) +} + +pub(super) fn x402_offer_matches_selector( + offer: &X402Offer, + selector: &str, + chain_store: &BeamChains, +) -> bool { + if let Some(chain) = resolved_x402_offer_chain(offer, chain_store) { + return selector_matches_chain( + selector, + chain_store, + Some(chain.chain_id), + Some(chain.key.as_str()), + Some(chain.display_name.as_str()), + chain.aliases.iter().map(String::as_str), + ); + } + + selector_matches_chain( + selector, + chain_store, + chain_id_from_network(&offer.network), + Some(offer.network.as_str()), + None, + std::iter::empty(), + ) +} + +pub(super) fn x402_offer_matches_payment_chain( + offer: &X402Offer, + chain: &PaymentChain, + chain_store: &BeamChains, +) -> bool { + if let Some(resolved_chain) = resolved_x402_offer_chain(offer, chain_store) { + return resolved_chain.chain_id == chain.chain_id; + } + + let network = offer.network.trim(); + chain_id_from_network(network) == Some(chain.chain_id) + || network.eq_ignore_ascii_case(&chain.key) + || network.eq_ignore_ascii_case(&chain.display_name) + || chain + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(network)) +} + +pub(super) fn summarize_x402_offer_chains( + offers: &[X402Offer], + chain_store: &BeamChains, +) -> String { + offers + .iter() + .filter_map(|offer| x402_offer_chain_summary(offer, chain_store)) + .collect::>() + .into_iter() + .collect::>() + .join(", ") +} + +fn selector_matches_chain<'a>( + selector: &str, + chain_store: &BeamChains, + chain_id: Option, + key: Option<&str>, + display_name: Option<&str>, + aliases: impl IntoIterator, +) -> bool { + let selector = selector.trim(); + if selector.is_empty() { + return false; + } + + if let Ok(desired_chain) = find_chain(selector, chain_store) { + return chain_id == Some(desired_chain.chain_id) + || key.is_some_and(|value| value.eq_ignore_ascii_case(&desired_chain.key)); + } + + chain_id.is_some_and(|value| selector == value.to_string()) + || key.is_some_and(|value| value.eq_ignore_ascii_case(selector)) + || display_name.is_some_and(|value| value.eq_ignore_ascii_case(selector)) + || aliases + .into_iter() + .any(|alias| alias.eq_ignore_ascii_case(selector)) +} + +fn resolved_x402_offer_chain(offer: &X402Offer, chain_store: &BeamChains) -> Option { + chain_id_from_network(&offer.network) + .map(|chain_id| chain_id.to_string()) + .and_then(|selector| find_chain(&selector, chain_store).ok()) + .or_else(|| find_chain(&offer.network, chain_store).ok()) +} + +fn x402_offer_chain_summary(offer: &X402Offer, chain_store: &BeamChains) -> Option { + if let Some(chain_id) = chain_id_from_network(&offer.network) { + return Some(payment_chain_summary(chain_id, chain_store)); + } + + if let Ok(chain) = find_chain(&offer.network, chain_store) { + return Some(format!("{} ({})", chain.display_name, chain.chain_id)); + } + + let network = offer.network.trim(); + (!network.is_empty()).then(|| network.to_string()) +} + +pub(super) fn payment_chain_summary(chain_id: u64, chain_store: &BeamChains) -> String { + find_chain(&chain_id.to_string(), chain_store) + .map(|chain| format!("{} ({})", chain.display_name, chain.chain_id)) + .unwrap_or_else(|_| chain_id.to_string()) +} diff --git a/pkg/beam-cli/src/commands/fetch/payment/prepare.rs b/pkg/beam-cli/src/commands/fetch/payment/prepare.rs new file mode 100644 index 0000000..a9b6e7d --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/payment/prepare.rs @@ -0,0 +1,283 @@ +// lint-long-file-override allow-max-lines=300 +use contracts::U256; +use serde_json::Value; + +use crate::{ + cli::FetchArgs, + error::{Error, Result}, + evm::{erc20_balance, format_units, native_balance, parse_units}, + runtime::{BeamApp, parse_address}, +}; + +use super::{ + PaymentAssetKind, PreparedPayment, + chain_match::{ + payment_chain_summary, summarize_x402_offer_chains, x402_offer_matches_payment_chain, + x402_offer_matches_selector, + }, + resolve::{ + chain_id_from_network, estimate_payment_gas, resolve_payment_asset, resolve_payment_chain, + }, + selection::selected_payment_chain, +}; +use crate::commands::fetch::protocol::{AmountValue, MppChallenge, X402Challenge, X402Offer}; + +pub(crate) async fn prepare_x402_payment( + app: &BeamApp, + args: &FetchArgs, + challenge: &X402Challenge, +) -> Result { + let selected_chain = selected_payment_chain(app).await?; + let offers = prioritized_x402_offers(app, args, challenge, selected_chain.as_ref()).await?; + let mut last_error = None; + let mut had_max_fee_exceeded = false; + let mut had_insufficient_balance = false; + + for offer in offers { + match prepare_offer_payment(app, offer).await { + Ok(payment) => { + if let Some(max_fee) = args.max_fee.as_ref() { + match payment.ensure_max_fee_allows(max_fee) { + Ok(()) => {} + Err(Error::FetchPaymentExceedsMaxFee) => { + had_max_fee_exceeded = true; + continue; + } + Err(err) => return Err(err), + } + } + + if payment_has_sufficient_balance(&payment).await? { + return Ok(payment); + } + + had_insufficient_balance = true; + } + Err(err) => last_error = Some(err), + } + } + + if had_insufficient_balance { + return Err(Error::FetchPaymentInsufficientBalance); + } + + if had_max_fee_exceeded { + return Err(Error::FetchPaymentExceedsMaxFee); + } + + Err(last_error.unwrap_or(Error::FetchInvalidPaymentResponse)) +} + +pub(crate) async fn prepare_mpp_payment( + app: &BeamApp, + challenge: &MppChallenge, +) -> Result { + let auth = challenge + .auth + .as_ref() + .ok_or(Error::FetchInvalidPaymentResponse)?; + + if challenge.request.chain_id.is_none() + && app.overrides.chain.is_none() + && app.overrides.rpc.is_none() + { + return Err(Error::FetchPaymentChainRequired); + } + + ensure_mpp_chain_matches_override(app, challenge.request.chain_id).await?; + + let network = challenge + .request + .chain_id + .map(|chain_id| format!("eip155:{chain_id}")); + let description = challenge + .request + .description + .clone() + .or_else(|| challenge.problem.detail.clone()); + let scheme = auth.method.clone(); + + prepare_payment( + app, + PaymentRequest { + accepted: Value::Null, + amount: &challenge.request.amount, + asset_id: &challenge.request.currency, + chain_hint: None, + chain_id_hint: challenge.request.chain_id, + description, + network, + recipient: &challenge.request.recipient, + scheme, + }, + ) + .await +} + +async fn prepare_payment(app: &BeamApp, request: PaymentRequest<'_>) -> Result { + let wallet = app.active_wallet().await?; + let payer = parse_address(&wallet.address).map_err(|_| Error::FetchInvalidPaymentResponse)?; + let (chain, client) = + resolve_payment_chain(app, request.chain_hint, request.chain_id_hint).await?; + let selected_chain = selected_payment_chain(app).await?; + let asset = resolve_payment_asset(app, &client, &chain, request.asset_id).await?; + let amount = parse_payment_amount(request.amount, asset.decimals)?; + let recipient = + parse_address(request.recipient).map_err(|_| Error::FetchInvalidPaymentResponse)?; + let gas = estimate_payment_gas(&client, payer, recipient, amount, &asset).await?; + + Ok(PreparedPayment { + accepted: request.accepted, + amount, + amount_display: format_units(amount, asset.decimals), + asset, + asset_id: request.asset_id.to_string(), + network: request + .network + .unwrap_or_else(|| format!("eip155:{}", chain.chain_id)), + chain, + client, + description: request.description, + gas, + payer, + recipient, + selected_chain, + scheme: request.scheme, + }) +} + +async fn prepare_offer_payment(app: &BeamApp, offer: &X402Offer) -> Result { + prepare_payment( + app, + PaymentRequest { + accepted: offer.raw.clone(), + amount: &offer.amount, + asset_id: &offer.asset, + chain_hint: Some(&offer.network), + chain_id_hint: chain_id_from_network(&offer.network), + description: None, + network: Some(offer.network.clone()), + recipient: &offer.pay_to, + scheme: offer.scheme.clone(), + }, + ) + .await +} + +async fn prioritized_x402_offers<'a>( + app: &BeamApp, + args: &FetchArgs, + challenge: &'a X402Challenge, + selected_chain: Option<&super::PaymentChain>, +) -> Result> { + let chain_store = app.chain_store.get().await; + let mut offers = challenge.offers.iter().collect::>(); + let selector = app.overrides.chain.as_deref(); + let allowlist_active = selector.is_none() && !args.allowed_chains.is_empty(); + + if let Some(selector) = selector { + offers.retain(|offer| x402_offer_matches_selector(offer, selector, &chain_store)); + } else if allowlist_active { + offers.retain(|offer| { + args.allowed_chains + .iter() + .any(|allowed| x402_offer_matches_selector(offer, allowed, &chain_store)) + }); + } + + if offers.is_empty() { + let challenge = (!challenge.offers.is_empty()) + .then(|| summarize_x402_offer_chains(&challenge.offers, &chain_store)) + .ok_or(Error::FetchInvalidPaymentResponse)?; + return if let Some(selector) = selector { + Err(Error::FetchPaymentChainMismatch { + challenge, + selected: selected_chain.map_or_else(|| selector.into(), |chain| chain.summary()), + }) + } else if allowlist_active { + Err(Error::FetchPaymentChainNotAllowed { chain: challenge }) + } else { + Err(Error::FetchInvalidPaymentResponse) + }; + } + + if selector.is_none() + && let Some(selected_chain) = selected_chain + { + let (mut preferred, mut remaining): (Vec<_>, Vec<_>) = + offers.into_iter().partition(|offer| { + x402_offer_matches_payment_chain(offer, selected_chain, &chain_store) + }); + preferred.append(&mut remaining); + offers = preferred; + } + + Ok(offers) +} + +async fn payment_has_sufficient_balance(payment: &PreparedPayment) -> Result { + match payment.asset.kind { + PaymentAssetKind::Native => { + let balance = native_balance(&payment.client, payment.payer).await?; + Ok(balance >= payment.amount.saturating_add(payment.gas.fee)) + } + PaymentAssetKind::Erc20(token) => { + let native = native_balance(&payment.client, payment.payer).await?; + if native < payment.gas.fee { + return Ok(false); + } + + let token_balance = erc20_balance(&payment.client, token, payment.payer).await?; + Ok(token_balance >= payment.amount) + } + } +} + +async fn ensure_mpp_chain_matches_override( + app: &BeamApp, + challenge_chain_id: Option, +) -> Result<()> { + if app.overrides.chain.is_none() { + return Ok(()); + } + + let Some(challenge_chain_id) = challenge_chain_id else { + return Ok(()); + }; + let Some(selected_chain) = selected_payment_chain(app).await? else { + return Ok(()); + }; + if selected_chain.chain_id == challenge_chain_id { + return Ok(()); + } + + let chain_store = app.chain_store.get().await; + let challenge = payment_chain_summary(challenge_chain_id, &chain_store); + + Err(Error::FetchPaymentChainMismatch { + challenge, + selected: selected_chain.summary(), + }) +} + +fn parse_payment_amount(amount: &AmountValue, decimals: u8) -> Result { + match amount { + AmountValue::Atomic(value) => { + U256::from_dec_str(value).map_err(|_| Error::FetchInvalidPaymentResponse) + } + AmountValue::Human(value) => parse_units(value, usize::from(decimals)) + .map_err(|_| Error::FetchInvalidPaymentResponse), + } +} + +struct PaymentRequest<'a> { + accepted: Value, + amount: &'a AmountValue, + asset_id: &'a str, + chain_hint: Option<&'a str>, + chain_id_hint: Option, + description: Option, + network: Option, + recipient: &'a str, + scheme: String, +} diff --git a/pkg/beam-cli/src/commands/fetch/payment/resolve.rs b/pkg/beam-cli/src/commands/fetch/payment/resolve.rs new file mode 100644 index 0000000..ecdfd7c --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/payment/resolve.rs @@ -0,0 +1,234 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use contracts::{Address, Client, U256}; +use web3::{ + ethabi::StateMutability, + types::{Bytes, CallRequest}, +}; + +use crate::{ + abi::{encode_input, parse_function}, + chains::{ChainEntry, ensure_client_matches_chain_id, find_chain}, + error::{Error, Result}, + evm::erc20_decimals, + output::with_loading, + runtime::{BeamApp, parse_address}, +}; + +use super::{GasEstimate, PaymentAsset, PaymentAssetKind, PaymentChain}; + +pub(super) async fn resolve_payment_chain( + app: &BeamApp, + chain_hint: Option<&str>, + chain_id_hint: Option, +) -> Result<(PaymentChain, Client)> { + let selector = chain_id_hint + .map(|value| value.to_string()) + .or_else(|| chain_hint.and_then(selector_from_network)) + .or_else(|| app.overrides.chain.clone()); + + if selector.is_none() && app.overrides.rpc.is_none() { + return Err(Error::FetchPaymentChainRequired); + } + + let config = app.config_store.get().await; + let chain_store = app.chain_store.get().await; + + if let Some(selector) = selector.clone() + && let Ok(entry) = find_chain(&selector, &chain_store) + { + let rpc_url = app + .overrides + .rpc + .clone() + .or_else(|| { + config + .rpc_config_for_chain(&entry) + .map(|rpc| rpc.default_rpc) + }) + .ok_or_else(|| Error::NoRpcConfigured { + chain: entry.key.clone(), + })?; + let client = connect_payment_client(app, &entry, &rpc_url, Some(entry.chain_id)).await?; + + return Ok((payment_chain_from_entry(&entry, entry.chain_id), client)); + } + + let rpc_url = app + .overrides + .rpc + .clone() + .ok_or_else(|| Error::UnknownChain { + chain: selector.clone().unwrap_or_else(|| "payment".to_string()), + })?; + let key = selector + .clone() + .or_else(|| chain_hint.map(network_key)) + .unwrap_or_else(|| "payment".to_string()); + let fallback_entry = ChainEntry { + aliases: Vec::new(), + chain_id: chain_id_hint.unwrap_or_default(), + display_name: key.clone(), + is_builtin: false, + key, + native_symbol: "ETH".to_string(), + }; + let client = connect_payment_client(app, &fallback_entry, &rpc_url, chain_id_hint).await?; + let chain_id = match chain_id_hint { + Some(chain_id) => chain_id, + None => client + .chain_id_contracts() + .await + .context("fetch beam fetch payment chain id")? + .low_u64(), + }; + let entry = find_chain(&chain_id.to_string(), &chain_store).unwrap_or(ChainEntry { + chain_id, + ..fallback_entry + }); + + Ok((payment_chain_from_entry(&entry, chain_id), client)) +} + +pub(super) async fn resolve_payment_asset( + app: &BeamApp, + client: &Client, + chain: &PaymentChain, + asset_id: &str, +) -> Result { + if is_native_asset(asset_id, &chain.native_symbol) { + return Ok(PaymentAsset { + decimals: 18, + kind: PaymentAssetKind::Native, + label: chain.native_symbol.clone(), + }); + } + + if let Ok(token) = app.token_for_chain(asset_id, &chain.key).await { + let decimals = match token.decimals { + Some(decimals) => decimals, + None => erc20_decimals(client, token.address).await?, + }; + return Ok(PaymentAsset { + decimals, + kind: PaymentAssetKind::Erc20(token.address), + label: token.label, + }); + } + + let address = parse_address(asset_id).map_err(|_| Error::FetchInvalidPaymentResponse)?; + let decimals = erc20_decimals(client, address).await?; + + Ok(PaymentAsset { + decimals, + kind: PaymentAssetKind::Erc20(address), + label: format!("{address:#x}"), + }) +} + +pub(super) async fn estimate_payment_gas( + client: &Client, + payer: Address, + recipient: Address, + amount: U256, + asset: &PaymentAsset, +) -> Result { + let request = match asset.kind { + PaymentAssetKind::Native => CallRequest { + from: Some(payer), + to: Some(recipient), + value: Some(amount), + ..Default::default() + }, + PaymentAssetKind::Erc20(token) => { + let function = + parse_function("transfer(address,uint256)", StateMutability::NonPayable)?; + let args = [format!("{recipient:#x}"), amount.to_string()]; + let data = encode_input(&function, &args)?; + + CallRequest { + data: Some(Bytes(data)), + from: Some(payer), + to: Some(token), + value: Some(U256::zero()), + ..Default::default() + } + } + }; + let gas = client + .estimate_gas(request, None) + .await + .context("estimate beam fetch payment gas")?; + let gas_limit = gas + gas / 5; + let gas_price = client + .fast_gas_price() + .await + .context("fetch beam fetch payment gas price")?; + + Ok(GasEstimate { + fee: gas_limit * gas_price, + gas_limit, + gas_price, + }) +} + +pub(super) fn chain_id_from_network(network: &str) -> Option { + network + .trim() + .strip_prefix("eip155:") + .and_then(|value| value.parse::().ok()) +} + +async fn connect_payment_client( + app: &BeamApp, + entry: &ChainEntry, + rpc_url: &str, + expected_chain_id: Option, +) -> Result { + let rpc_url = rpc_url.to_string(); + let key = entry.key.clone(); + let client = with_loading( + app.output_mode, + format!("Connecting to {} RPC...", key), + async move { + Client::try_new(&rpc_url, None).map_err(|_| Error::InvalidRpcUrl { + value: rpc_url.clone(), + }) + }, + ) + .await?; + + if let Some(expected_chain_id) = expected_chain_id { + ensure_client_matches_chain_id(&entry.key, expected_chain_id, &client).await?; + } + + Ok(client) +} + +fn selector_from_network(network: &str) -> Option { + chain_id_from_network(network) + .map(|value| value.to_string()) + .or_else(|| (!network.trim().is_empty()).then(|| network.trim().to_string())) +} + +fn network_key(network: &str) -> String { + network.trim().replace([':', '_'], "-").to_ascii_lowercase() +} + +fn is_native_asset(asset_id: &str, native_symbol: &str) -> bool { + let normalized = asset_id.trim().to_ascii_lowercase(); + normalized == "native" + || normalized == native_symbol.to_ascii_lowercase() + || normalized == "0x0000000000000000000000000000000000000000" + || normalized == "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +} + +pub(super) fn payment_chain_from_entry(entry: &ChainEntry, chain_id: u64) -> PaymentChain { + PaymentChain { + aliases: entry.aliases.clone(), + chain_id, + display_name: entry.display_name.clone(), + key: entry.key.clone(), + native_symbol: entry.native_symbol.clone(), + } +} diff --git a/pkg/beam-cli/src/commands/fetch/payment/selection.rs b/pkg/beam-cli/src/commands/fetch/payment/selection.rs new file mode 100644 index 0000000..cd7de16 --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/payment/selection.rs @@ -0,0 +1,20 @@ +use crate::{chains::find_chain, error::Result, runtime::BeamApp}; + +use super::{PaymentChain, resolve::payment_chain_from_entry}; + +pub(super) async fn selected_payment_chain(app: &BeamApp) -> Result> { + if app.overrides.rpc.is_some() && app.overrides.chain.is_none() { + return Ok(None); + } + + let config = app.config_store.get().await; + let selection = app + .overrides + .chain + .clone() + .unwrap_or_else(|| config.default_chain.clone()); + let chain_store = app.chain_store.get().await; + let entry = find_chain(&selection, &chain_store)?; + + Ok(Some(payment_chain_from_entry(&entry, entry.chain_id))) +} diff --git a/pkg/beam-cli/src/commands/fetch/protocol.rs b/pkg/beam-cli/src/commands/fetch/protocol.rs new file mode 100644 index 0000000..b9715c5 --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/protocol.rs @@ -0,0 +1,166 @@ +mod mpp; +mod x402; + +use base64::{ + Engine, + engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}, +}; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use serde_json::Value; + +use crate::error::{Error, Result}; + +use super::payment::ExecutedPayment; + +pub(super) const PAYMENT_REQUIRED_HEADER: &str = "payment-required"; +pub(super) const PAYMENT_SIGNATURE_HEADER: &str = "payment-signature"; +pub(super) const X_PAYMENT_HEADER: &str = "x-payment"; +pub(super) const WWW_AUTHENTICATE_HEADER: &str = "www-authenticate"; +pub(super) const MPP_PROBLEM_TYPE: &str = "https://paymentauth.org/problems/payment-required"; + +#[derive(Clone, Debug)] +pub(crate) enum PaymentChallenge { + X402(X402Challenge), + Mpp(Box), +} + +#[derive(Clone, Debug)] +pub(crate) struct X402Challenge { + pub offers: Vec, + pub resource: Option, + pub version: u8, +} + +#[derive(Clone, Debug)] +pub(crate) struct X402Offer { + pub amount: AmountValue, + pub asset: String, + pub network: String, + pub pay_to: String, + pub raw: Value, + pub scheme: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct MppChallenge { + pub auth: Option, + pub problem: MppProblem, + pub request: MppPaymentRequest, +} + +#[derive(Clone, Debug)] +pub(crate) struct MppProblem { + pub challenge_id: String, + pub detail: Option, + pub title: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct MppAuthChallenge { + pub description: Option, + pub digest: Option, + pub expires: Option, + pub id: String, + pub intent: String, + pub method: String, + pub opaque: Option, + pub realm: String, + pub request: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct MppPaymentRequest { + pub amount: AmountValue, + pub chain_id: Option, + pub currency: String, + pub description: Option, + pub recipient: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AmountValue { + Atomic(String), + Human(String), +} + +#[derive(Clone, Debug)] +pub(crate) struct RetryHeader { + pub name: HeaderName, + pub value: HeaderValue, +} + +impl PaymentChallenge { + pub(crate) fn protocol_name(&self) -> &'static str { + match self { + Self::X402(_) => "x402", + Self::Mpp(_) => "MPP", + } + } + + pub(crate) fn describe(&self) -> String { + match self { + Self::X402(challenge) => x402::describe_x402(challenge), + Self::Mpp(challenge) => mpp::describe_mpp(challenge), + } + } + + pub(crate) fn retry_header(&self, executed: &ExecutedPayment) -> Result { + match self { + Self::X402(challenge) => x402::build_x402_retry_header(challenge, executed), + Self::Mpp(challenge) => mpp::build_mpp_retry_header(challenge, executed), + } + } +} + +impl AmountValue { + pub(crate) fn raw(&self) -> &str { + match self { + Self::Atomic(value) | Self::Human(value) => value, + } + } +} + +pub(crate) fn parse_payment_challenge( + headers: &HeaderMap, + body: &[u8], +) -> Result> { + if let Some(encoded) = header_value(headers, PAYMENT_REQUIRED_HEADER) { + return x402::parse_x402_from_header(&encoded).map(Some); + } + + if body.is_empty() { + return Ok(None); + } + + let value = match serde_json::from_slice::(body) { + Ok(value) => value, + Err(_) => return Ok(None), + }; + + if mpp::is_mpp_problem(&value) { + return mpp::parse_mpp(headers, value).map(Some); + } + + if value.get("accepts").is_some() { + return x402::parse_x402_from_value(value).map(Some); + } + + Ok(None) +} + +pub(super) fn header_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string) +} + +pub(super) fn decode_base64(value: &str) -> Result> { + for engine in [STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD] { + if let Ok(bytes) = engine.decode(value.trim()) { + return Ok(bytes); + } + } + + Err(Error::FetchInvalidPaymentResponse) +} diff --git a/pkg/beam-cli/src/commands/fetch/protocol/mpp.rs b/pkg/beam-cli/src/commands/fetch/protocol/mpp.rs new file mode 100644 index 0000000..2a3950b --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/protocol/mpp.rs @@ -0,0 +1,190 @@ +// lint-long-file-override allow-max-lines=280 +#[path = "mpp_auth.rs"] +mod auth; + +use base64::Engine; +use contextful::ResultContextExt; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + error::{Error, Result}, + human_output::sanitize_control_chars, +}; + +use super::{ + AmountValue, ExecutedPayment, MPP_PROBLEM_TYPE, MppAuthChallenge, MppChallenge, + MppPaymentRequest, MppProblem, PaymentChallenge, RetryHeader, decode_base64, +}; + +pub(super) fn is_mpp_problem(value: &Value) -> bool { + value + .get("type") + .and_then(Value::as_str) + .is_some_and(|value| value == MPP_PROBLEM_TYPE) + && value.get("challengeId").is_some() +} + +pub(super) fn parse_mpp( + headers: &reqwest::header::HeaderMap, + value: Value, +) -> Result { + let problem = serde_json::from_value::(value) + .map_err(|_| Error::FetchInvalidPaymentResponse)?; + let auth = auth::parse_mpp_auth_challenge(headers)?; + if problem.challenge_id != auth.id { + return Err(Error::FetchInvalidPaymentResponse); + } + let request = parse_mpp_request(&auth.request)?; + + Ok(PaymentChallenge::Mpp(Box::new(MppChallenge { + auth: Some(auth), + problem: MppProblem { + challenge_id: problem.challenge_id, + detail: problem.detail, + title: problem.title, + }, + request, + }))) +} + +pub(super) fn describe_mpp(challenge: &MppChallenge) -> String { + let mut lines = vec![ + "Payment required via MPP".to_string(), + format!( + "Challenge: {}", + sanitize_control_chars(&challenge.problem.challenge_id) + ), + ]; + + if let Some(title) = challenge.problem.title.as_ref() { + lines.push(format!("Title: {}", sanitize_control_chars(title))); + } + + if let Some(detail) = challenge.problem.detail.as_ref() { + lines.push(format!("Detail: {}", sanitize_control_chars(detail))); + } + + if let Some(auth) = challenge.auth.as_ref() { + lines.push(format!( + "Method: {} {} {} {}", + sanitize_control_chars(&auth.method), + sanitize_control_chars(challenge.request.amount.raw()), + sanitize_control_chars(&challenge.request.currency), + sanitize_control_chars(&challenge.request.recipient), + )); + } + + lines.join("\n") +} + +pub(super) fn build_mpp_retry_header( + challenge: &MppChallenge, + executed: &ExecutedPayment, +) -> Result { + let auth = challenge + .auth + .as_ref() + .ok_or(Error::FetchInvalidPaymentResponse)?; + let credential = json!({ + "challenge": auth.challenge_json(), + "payload": executed.proof.clone(), + "source": executed.source.clone(), + }); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&credential).context("serialize beam mpp credential")?); + let value = format!("Payment {encoded}"); + let header_value = reqwest::header::HeaderValue::from_str(&value) + .map_err(|_| Error::FetchInvalidPaymentResponse)?; + + Ok(RetryHeader { + name: reqwest::header::HeaderName::from_static("authorization"), + value: header_value, + }) +} + +fn parse_mpp_request(encoded_request: &str) -> Result { + let bytes = decode_base64(encoded_request)?; + let value = serde_json::from_slice::(&bytes).context("parse beam mpp request json")?; + let raw = serde_json::from_value::(value) + .map_err(|_| Error::FetchInvalidPaymentResponse)?; + let amount = amount_from_json(&raw.amount)?; + + Ok(MppPaymentRequest { + amount, + chain_id: raw + .method_details + .as_ref() + .and_then(|details| details.chain_id) + .or(raw.chain_id), + currency: raw.currency.ok_or(Error::FetchInvalidPaymentResponse)?, + description: raw.description, + recipient: raw.recipient.ok_or(Error::FetchInvalidPaymentResponse)?, + }) +} + +fn amount_from_json(value: &Value) -> Result { + match value { + Value::Number(number) => Ok(AmountValue::Atomic(number.to_string())), + Value::String(value) if value.contains('.') => Ok(AmountValue::Human(value.clone())), + Value::String(value) => Ok(AmountValue::Atomic(value.clone())), + _ => Err(Error::FetchInvalidPaymentResponse), + } +} + +impl MppAuthChallenge { + fn challenge_json(&self) -> Value { + let mut value = json!({ + "id": self.id, + "realm": self.realm, + "method": self.method, + "intent": self.intent, + "request": self.request, + }); + + if let Some(object) = value.as_object_mut() { + if let Some(description) = self.description.as_ref() { + object.insert( + "description".to_string(), + Value::String(description.clone()), + ); + } + if let Some(digest) = self.digest.as_ref() { + object.insert("digest".to_string(), Value::String(digest.clone())); + } + if let Some(expires) = self.expires.as_ref() { + object.insert("expires".to_string(), Value::String(expires.clone())); + } + if let Some(opaque) = self.opaque.as_ref() { + object.insert("opaque".to_string(), Value::String(opaque.clone())); + } + } + + value + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMppProblem { + challenge_id: String, + detail: Option, + title: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMppRequest { + amount: Value, + chain_id: Option, + currency: Option, + description: Option, + method_details: Option, + recipient: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawMppMethodDetails { + chain_id: Option, +} diff --git a/pkg/beam-cli/src/commands/fetch/protocol/mpp_auth.rs b/pkg/beam-cli/src/commands/fetch/protocol/mpp_auth.rs new file mode 100644 index 0000000..00b5fd3 --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/protocol/mpp_auth.rs @@ -0,0 +1,172 @@ +use std::collections::BTreeMap; + +use crate::error::{Error, Result}; + +use super::super::WWW_AUTHENTICATE_HEADER; +use super::MppAuthChallenge; + +pub(super) fn parse_mpp_auth_challenge( + headers: &reqwest::header::HeaderMap, +) -> Result { + for value in headers.get_all(WWW_AUTHENTICATE_HEADER) { + let value = value + .to_str() + .map_err(|_| Error::FetchInvalidPaymentResponse)?; + if let Some(auth) = extract_payment_auth_params(value) { + return parse_payment_auth_params(auth); + } + } + + Err(Error::FetchInvalidPaymentResponse) +} + +fn parse_payment_auth_params(input: &str) -> Result { + let params = parse_auth_params(input)?; + + Ok(MppAuthChallenge { + description: params.get("description").cloned(), + digest: params.get("digest").cloned(), + expires: params.get("expires").cloned(), + id: required_auth_param(¶ms, "id")?, + intent: required_auth_param(¶ms, "intent")?, + method: required_auth_param(¶ms, "method")?, + opaque: params.get("opaque").cloned(), + realm: required_auth_param(¶ms, "realm")?, + request: required_auth_param(¶ms, "request")?, + }) +} + +fn extract_payment_auth_params(header: &str) -> Option<&str> { + let mut candidate_starts = vec![0]; + let mut escaped = false; + let mut in_quotes = false; + + for (index, ch) in header.char_indices() { + if escaped { + escaped = false; + continue; + } + + match ch { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => candidate_starts.push(index + 1), + _ => {} + } + } + + for start in candidate_starts { + let candidate = header[start..].trim_start(); + let Some(auth) = strip_auth_scheme(candidate, "payment") else { + continue; + }; + let auth_end = payment_auth_params_len(auth); + return Some(auth[..auth_end].trim_end()); + } + + None +} + +fn strip_auth_scheme<'a>(input: &'a str, scheme: &str) -> Option<&'a str> { + let input = input.trim_start(); + let scheme_end = input.find(char::is_whitespace)?; + let auth_scheme = &input[..scheme_end]; + if !auth_scheme.eq_ignore_ascii_case(scheme) { + return None; + } + + let params = input[scheme_end..].trim_start(); + (!params.is_empty()).then_some(params) +} + +fn payment_auth_params_len(input: &str) -> usize { + let mut escaped = false; + let mut in_quotes = false; + + for (index, ch) in input.char_indices() { + if escaped { + escaped = false; + continue; + } + + match ch { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + ',' if !in_quotes && !starts_auth_param(&input[index + 1..]) => return index, + _ => {} + } + } + + input.len() +} + +fn starts_auth_param(input: &str) -> bool { + let input = input.trim_start(); + let token_end = input + .find(|ch: char| ch.is_whitespace() || ch == ',' || ch == '=') + .unwrap_or(input.len()); + if token_end == 0 { + return false; + } + + input[token_end..].trim_start().starts_with('=') +} + +fn parse_auth_params(input: &str) -> Result> { + let mut params = BTreeMap::new(); + let mut cursor = input.trim(); + + while !cursor.is_empty() { + let eq = cursor.find('=').ok_or(Error::FetchInvalidPaymentResponse)?; + let key = cursor[..eq].trim(); + if key.is_empty() { + return Err(Error::FetchInvalidPaymentResponse); + } + + cursor = cursor[eq + 1..].trim_start(); + let (value, rest) = parse_auth_value(cursor)?; + params.insert(key.to_string(), value); + cursor = rest.trim_start(); + + if let Some(remaining) = cursor.strip_prefix(',') { + cursor = remaining.trim_start(); + } else if !cursor.is_empty() { + return Err(Error::FetchInvalidPaymentResponse); + } + } + + Ok(params) +} + +fn parse_auth_value(input: &str) -> Result<(String, &str)> { + if let Some(rest) = input.strip_prefix('"') { + let mut escaped = false; + let mut value = String::new(); + + for (index, ch) in rest.char_indices() { + if escaped { + value.push(ch); + escaped = false; + continue; + } + + match ch { + '\\' => escaped = true, + '"' => return Ok((value, &rest[index + 1..])), + _ => value.push(ch), + } + } + + return Err(Error::FetchInvalidPaymentResponse); + } + + let next_comma = input.find(',').unwrap_or(input.len()); + Ok((input[..next_comma].trim().to_string(), &input[next_comma..])) +} + +fn required_auth_param(params: &BTreeMap, name: &str) -> Result { + params + .get(name) + .cloned() + .ok_or(Error::FetchInvalidPaymentResponse) +} diff --git a/pkg/beam-cli/src/commands/fetch/protocol/x402.rs b/pkg/beam-cli/src/commands/fetch/protocol/x402.rs new file mode 100644 index 0000000..262b414 --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/protocol/x402.rs @@ -0,0 +1,153 @@ +use base64::Engine; +use contextful::ResultContextExt; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + error::{Error, Result}, + human_output::sanitize_control_chars, +}; + +use super::{ + AmountValue, ExecutedPayment, PAYMENT_SIGNATURE_HEADER, PaymentChallenge, RetryHeader, + X_PAYMENT_HEADER, X402Challenge, X402Offer, decode_base64, +}; + +pub(super) fn parse_x402_from_header(encoded: &str) -> Result { + let bytes = decode_base64(encoded)?; + let value = serde_json::from_slice::(&bytes).context("parse beam x402 header json")?; + parse_x402_from_value(value) +} + +pub(super) fn parse_x402_from_value(value: Value) -> Result { + let raw = serde_json::from_value::(value) + .map_err(|_| Error::FetchInvalidPaymentResponse)?; + if raw.accepts.is_empty() { + return Err(Error::FetchInvalidPaymentResponse); + } + + let version = raw + .x402_version + .unwrap_or_else(|| infer_x402_version(raw.resource.as_ref())); + let mut offers = Vec::with_capacity(raw.accepts.len()); + + for raw_offer in raw.accepts { + offers.push(parse_x402_offer(raw_offer)?); + } + + Ok(PaymentChallenge::X402(X402Challenge { + offers, + resource: raw.resource, + version, + })) +} + +pub(super) fn describe_x402(challenge: &X402Challenge) -> String { + let mut lines = vec![ + "Payment required via x402".to_string(), + format!("Offers: {}", challenge.offers.len()), + ]; + + for offer in &challenge.offers { + lines.push(format!( + "- {} {} on {} to {} ({})", + sanitize_control_chars(offer.amount.raw()), + sanitize_control_chars(&offer.asset), + sanitize_control_chars(&offer.network), + sanitize_control_chars(&offer.pay_to), + sanitize_control_chars(&offer.scheme), + )); + } + + lines.join("\n") +} + +pub(super) fn build_x402_retry_header( + challenge: &X402Challenge, + executed: &ExecutedPayment, +) -> Result { + let payload = if challenge.version >= 2 { + json!({ + "x402Version": 2, + "resource": challenge.resource.clone(), + "accepted": executed.accepted.clone(), + "payload": executed.proof.clone(), + }) + } else { + json!({ + "x402Version": 1, + "scheme": executed.scheme, + "network": executed.network, + "payload": executed.proof.clone(), + }) + }; + let encoded = base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&payload).context("serialize beam x402 proof")?); + let header_name = if challenge.version >= 2 { + reqwest::header::HeaderName::from_static(PAYMENT_SIGNATURE_HEADER) + } else { + reqwest::header::HeaderName::from_static(X_PAYMENT_HEADER) + }; + let header_value = reqwest::header::HeaderValue::from_str(&encoded) + .map_err(|_| Error::FetchInvalidPaymentResponse)?; + + Ok(RetryHeader { + name: header_name, + value: header_value, + }) +} + +fn infer_x402_version(resource: Option<&Value>) -> u8 { + match resource { + Some(Value::Object(_)) => 2, + _ => 1, + } +} + +fn parse_x402_offer(raw: Value) -> Result { + let parsed = serde_json::from_value::(raw.clone()) + .map_err(|_| Error::FetchInvalidPaymentResponse)?; + let amount = parsed + .amount + .as_ref() + .or(parsed.max_amount_required.as_ref()) + .ok_or(Error::FetchInvalidPaymentResponse) + .and_then(amount_from_json)?; + + Ok(X402Offer { + amount, + asset: parsed.asset, + network: parsed.network, + pay_to: parsed.pay_to, + raw, + scheme: parsed.scheme, + }) +} + +fn amount_from_json(value: &Value) -> Result { + match value { + Value::Number(number) => Ok(AmountValue::Atomic(number.to_string())), + Value::String(value) if value.contains('.') => Ok(AmountValue::Human(value.clone())), + Value::String(value) => Ok(AmountValue::Atomic(value.clone())), + _ => Err(Error::FetchInvalidPaymentResponse), + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawX402Challenge { + accepts: Vec, + resource: Option, + x402_version: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawX402Offer { + amount: Option, + asset: String, + max_amount_required: Option, + network: String, + pay_to: String, + scheme: String, +} diff --git a/pkg/beam-cli/src/commands/fetch/retry.rs b/pkg/beam-cli/src/commands/fetch/retry.rs new file mode 100644 index 0000000..670129c --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/retry.rs @@ -0,0 +1,166 @@ +use std::sync::{Arc, Mutex, MutexGuard}; + +use reqwest::{ + Method, StatusCode, Url, + header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, TRANSFER_ENCODING}, + redirect::Policy, +}; + +use super::RequestSpec; + +#[derive(Clone, Debug, Default)] +pub(super) struct RedirectTracker { + followed_redirects: Arc>>, +} + +#[derive(Clone, Debug)] +struct RedirectStep { + next_url: Url, + status: StatusCode, +} + +impl RedirectTracker { + pub(super) fn clear(&self) { + self.followed_redirects().clear(); + } + + pub(super) fn effective_request_spec(&self, spec: &RequestSpec) -> RequestSpec { + let followed_redirects = self.followed_redirects(); + apply_followed_redirects(spec, followed_redirects.as_slice()) + } + + fn followed_redirects(&self) -> MutexGuard<'_, Vec> { + match self.followed_redirects.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn record_follow(&self, status: StatusCode, next_url: &Url) { + self.followed_redirects().push(RedirectStep { + next_url: next_url.clone(), + status, + }); + } +} + +pub(super) fn origin_locked_redirect_policy( + max_redirects: usize, + original_url: &Url, + redirect_tracker: RedirectTracker, +) -> Policy { + let original_origin = RequestOrigin::from_url(original_url); + + Policy::custom(move |attempt| { + if attempt.previous().len() >= max_redirects { + return attempt.error("too many redirects"); + } + + if original_origin.matches(attempt.url()) { + redirect_tracker.record_follow(attempt.status(), attempt.url()); + attempt.follow() + } else { + attempt.stop() + } + }) +} + +pub(super) fn retry_spec_for_challenge(spec: &RequestSpec, challenged_url: Url) -> RequestSpec { + if RequestOrigin::from_url(&spec.url).matches(&challenged_url) { + return RequestSpec { + url: challenged_url, + ..spec.clone() + }; + } + + // Cross-origin challenges must not replay origin-scoped request metadata. + RequestSpec { + body: None, + headers: HeaderMap::new(), + method: cross_origin_retry_method(&spec.method), + url: challenged_url, + } +} + +fn cross_origin_retry_method(method: &Method) -> Method { + if *method == Method::HEAD { + Method::HEAD + } else { + Method::GET + } +} + +fn apply_followed_redirects( + spec: &RequestSpec, + followed_redirects: &[RedirectStep], +) -> RequestSpec { + let mut effective_spec = spec.clone(); + + for redirect in followed_redirects { + effective_spec.url = redirect.next_url.clone(); + + match redirect.status { + StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => { + if effective_spec.method == Method::POST { + rewrite_request_as_get(&mut effective_spec); + } + } + StatusCode::SEE_OTHER => { + if effective_spec.method != Method::HEAD { + effective_spec.method = Method::GET; + } + + drop_request_payload(&mut effective_spec); + } + StatusCode::TEMPORARY_REDIRECT | StatusCode::PERMANENT_REDIRECT => {} + _ => {} + } + } + + effective_spec +} + +fn rewrite_request_as_get(spec: &mut RequestSpec) { + spec.method = Method::GET; + drop_request_payload(spec); +} + +fn drop_request_payload(spec: &mut RequestSpec) { + spec.body = None; + + for header in &[ + CONTENT_TYPE, + CONTENT_LENGTH, + CONTENT_ENCODING, + TRANSFER_ENCODING, + ] { + spec.headers.remove(header); + } +} + +#[derive(Clone, Debug)] +struct RequestOrigin { + host: Option, + port: Option, + scheme: String, +} + +impl RequestOrigin { + fn from_url(url: &Url) -> Self { + Self { + host: url.host_str().map(ToString::to_string), + port: url.port_or_known_default(), + scheme: url.scheme().to_string(), + } + } + + fn matches(&self, url: &Url) -> bool { + self.scheme == url.scheme() + && self.port == url.port_or_known_default() + && self + .host + .as_deref() + .zip(url.host_str()) + .is_some_and(|(expected, actual)| expected.eq_ignore_ascii_case(actual)) + } +} diff --git a/pkg/beam-cli/src/commands/interactive.rs b/pkg/beam-cli/src/commands/interactive.rs index 81ee6de..19f8720 100644 --- a/pkg/beam-cli/src/commands/interactive.rs +++ b/pkg/beam-cli/src/commands/interactive.rs @@ -115,7 +115,7 @@ async fn handle_line(app: &BeamApp, overrides: &mut InvocationOverrides, line: & run_with_interrupt_owner( interrupt_owner, handle_parsed_line(app, overrides, parsed), - tokio::signal::ctrl_c(), + tokio::signal::ctrl_c, ) .await } @@ -127,11 +127,11 @@ pub(crate) async fn handle_parsed_line( ) -> Result<()> { match parsed { ParsedLine::ReplCommand(args) => handle_repl_command(app, overrides, &args).await, - ParsedLine::Cli { args, cli } => { + ParsedLine::Cli { cli, global_flags } => { let command_app = BeamApp { overrides: merge_overrides(overrides, &cli.overrides()), - color_mode: resolved_color_mode(&args, &cli, app), - output_mode: resolved_output_mode(&args, &cli, app), + color_mode: resolved_color_mode(global_flags, &cli, app), + output_mode: resolved_output_mode(global_flags, &cli, app), ..app.clone() }; diff --git a/pkg/beam-cli/src/commands/interactive_history.rs b/pkg/beam-cli/src/commands/interactive_history.rs index 25658af..3e4e816 100644 --- a/pkg/beam-cli/src/commands/interactive_history.rs +++ b/pkg/beam-cli/src/commands/interactive_history.rs @@ -8,7 +8,7 @@ use rustyline::{ history::{DefaultHistory, History, SearchDirection, SearchResult}, }; -use crate::cli::Cli; +use crate::cli::{Cli, normalize_cli_args}; pub(crate) struct ReplHistory { inner: DefaultHistory, @@ -182,9 +182,9 @@ pub(crate) fn should_persist_history(line: &str) -> bool { } if let Some(args) = shlex::split(line) { - if let Ok(cli) = - Cli::try_parse_from(std::iter::once("beam").chain(args.iter().map(String::as_str))) - { + if let Ok(cli) = Cli::try_parse_from(normalize_cli_args( + std::iter::once("beam").chain(args.iter().map(String::as_str)), + )) { return match cli.command { Some(command) => !command.is_sensitive(), None => true, @@ -237,7 +237,7 @@ fn command_index(args: &[String]) -> Option { let flag = arg.split_once('=').map_or(arg, |(flag, _)| flag); if matches!( flag, - "--chain" | "--color" | "--from" | "--output" | "--rpc" + "--chain" | "--color" | "--format" | "--from" | "--output" | "--rpc" ) { index += if arg.contains('=') { 1 } else { 2 }; continue; diff --git a/pkg/beam-cli/src/commands/interactive_interrupt.rs b/pkg/beam-cli/src/commands/interactive_interrupt.rs index 88cc9ec..a42660e 100644 --- a/pkg/beam-cli/src/commands/interactive_interrupt.rs +++ b/pkg/beam-cli/src/commands/interactive_interrupt.rs @@ -1,17 +1,51 @@ +use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, +}; + +use contextful::ResultContextExt; +use tokio::task_local; + use crate::{ cli::{Command, Erc20Action}, - error::Result, - output::with_interrupt, + error::{Error, Result}, }; use super::interactive::ParsedLine; +task_local! { + static INTERRUPT_CONTROLLER: InterruptController; +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] pub(crate) enum InterruptOwner { Repl, Command, } +impl InterruptOwner { + fn from_atomic(value: u8) -> Self { + match value { + value if value == Self::Repl as u8 => Self::Repl, + value if value == Self::Command as u8 => Self::Command, + value => unreachable!("unknown interrupt owner {value}"), + } + } +} + +#[derive(Clone, Debug)] +struct InterruptController { + owner: Arc, +} + +#[derive(Debug)] +#[must_use = "keep the guard alive until command-owned interrupt handling should end"] +pub(crate) struct InterruptOwnerGuard { + controller: Option, + previous_owner: InterruptOwner, +} + impl ParsedLine { pub(crate) fn interrupt_owner(&self) -> InterruptOwner { match self { @@ -35,17 +69,104 @@ impl ParsedLine { } } -pub(crate) async fn run_with_interrupt_owner( +impl InterruptController { + fn new(owner: InterruptOwner) -> Self { + Self { + owner: Arc::new(AtomicU8::new(owner as u8)), + } + } + + fn owner(&self) -> InterruptOwner { + InterruptOwner::from_atomic(self.owner.load(Ordering::SeqCst)) + } + + fn set_owner(&self, owner: InterruptOwner) { + self.owner.store(owner as u8, Ordering::SeqCst); + } + + fn delegate_to_command(&self) -> InterruptOwnerGuard { + let previous_owner = InterruptOwner::from_atomic( + self.owner + .swap(InterruptOwner::Command as u8, Ordering::SeqCst), + ); + + InterruptOwnerGuard { + controller: Some(self.clone()), + previous_owner, + } + } +} + +impl InterruptOwnerGuard { + fn inactive() -> Self { + Self { + controller: None, + previous_owner: InterruptOwner::Repl, + } + } +} + +impl Drop for InterruptOwnerGuard { + fn drop(&mut self) { + if let Some(controller) = self.controller.as_ref() { + controller.set_owner(self.previous_owner); + } + } +} + +pub(crate) fn delegate_current_interrupt_to_command() -> InterruptOwnerGuard { + INTERRUPT_CONTROLLER + .try_with(InterruptController::delegate_to_command) + .unwrap_or_else(|_| InterruptOwnerGuard::inactive()) +} + +pub(crate) async fn run_with_interrupt_owner( owner: InterruptOwner, future: F, - cancel: C, + make_cancel: MakeCancel, +) -> Result +where + F: std::future::Future>, + MakeCancel: FnMut() -> C, + C: std::future::Future>, +{ + let controller = InterruptController::new(owner); + INTERRUPT_CONTROLLER + .scope( + controller.clone(), + run_with_interrupt_controller(controller, future, make_cancel), + ) + .await +} + +async fn run_with_interrupt_controller( + controller: InterruptController, + future: F, + mut make_cancel: MakeCancel, ) -> Result where F: std::future::Future>, + MakeCancel: FnMut() -> C, C: std::future::Future>, { - match owner { - InterruptOwner::Repl => with_interrupt(future, cancel).await, - InterruptOwner::Command => future.await, + if controller.owner() == InterruptOwner::Command { + return future.await; + } + + let mut future = std::pin::pin!(future); + + loop { + let cancel = make_cancel(); + tokio::pin!(cancel); + + tokio::select! { + output = &mut future => return output, + signal = &mut cancel => { + signal.context("listen for beam ctrl-c")?; + if controller.owner() == InterruptOwner::Repl { + return Err(Error::Interrupted); + } + } + } } } diff --git a/pkg/beam-cli/src/commands/interactive_parse.rs b/pkg/beam-cli/src/commands/interactive_parse.rs index 808bbf4..6e5e018 100644 --- a/pkg/beam-cli/src/commands/interactive_parse.rs +++ b/pkg/beam-cli/src/commands/interactive_parse.rs @@ -1,7 +1,8 @@ -use clap::Parser; +use clap::{ArgMatches, CommandFactory, FromArgMatches, parser::ValueSource}; +use contextful::ResultContextExt; use crate::{ - cli::Cli, + cli::{Cli, normalize_cli_args}, display::ColorMode, error::{Error, Result}, output::OutputMode, @@ -14,8 +15,19 @@ pub(crate) fn parse_line(line: &str) -> Result { } let args = parse_shell_words(line)?; - match Cli::try_parse_from(std::iter::once("beam").chain(args.iter().map(String::as_str))) { - Ok(cli) => Ok(ParsedLine::Cli { args, cli }), + match Cli::command().try_get_matches_from(normalize_cli_args( + std::iter::once("beam").chain(args.iter().map(String::as_str)), + )) { + Ok(matches) => { + let cli = Cli::from_arg_matches(&matches).context("build beam repl cli from clap")?; + Ok(ParsedLine::Cli { + cli: Box::new(cli), + global_flags: ParsedGlobalFlags { + color_explicit: is_command_line_value(&matches, "color"), + output_explicit: is_command_line_value(&matches, "output"), + }, + }) + } Err(err) => Ok(ParsedLine::CliError(err)), } } @@ -62,22 +74,39 @@ pub(crate) fn merge_overrides( } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct ParsedGlobalFlags { + pub(crate) color_explicit: bool, + pub(crate) output_explicit: bool, +} + pub(crate) enum ParsedLine { ReplCommand(Vec), - Cli { args: Vec, cli: Cli }, + Cli { + cli: Box, + global_flags: ParsedGlobalFlags, + }, CliError(clap::Error), } -pub(crate) fn resolved_color_mode(args: &[String], cli: &Cli, app: &BeamApp) -> ColorMode { - if has_long_flag(args, "--color") { +pub(crate) fn resolved_color_mode( + global_flags: ParsedGlobalFlags, + cli: &Cli, + app: &BeamApp, +) -> ColorMode { + if global_flags.color_explicit { cli.color } else { app.color_mode } } -pub(crate) fn resolved_output_mode(args: &[String], cli: &Cli, app: &BeamApp) -> OutputMode { - if has_long_flag(args, "--output") { +pub(crate) fn resolved_output_mode( + global_flags: ParsedGlobalFlags, + cli: &Cli, + app: &BeamApp, +) -> OutputMode { + if global_flags.output_explicit { cli.output } else { app.output_mode @@ -138,11 +167,6 @@ fn is_cli_subcommand_invocation(command: &str, args: &[String]) -> bool { ) } -fn has_long_flag(args: &[String], long_flag: &str) -> bool { - args.iter().any(|arg| { - arg == long_flag - || arg - .strip_prefix(long_flag) - .is_some_and(|suffix| suffix.starts_with('=')) - }) +fn is_command_line_value(matches: &ArgMatches, arg_id: &str) -> bool { + matches.value_source(arg_id) == Some(ValueSource::CommandLine) } diff --git a/pkg/beam-cli/src/commands/mod.rs b/pkg/beam-cli/src/commands/mod.rs index 57b8201..2dc675b 100644 --- a/pkg/beam-cli/src/commands/mod.rs +++ b/pkg/beam-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod block; pub mod call; pub mod chain; pub mod erc20; +pub mod fetch; pub mod interactive; pub(crate) mod interactive_helper; pub(crate) mod interactive_history; @@ -36,6 +37,7 @@ pub async fn run(app: &BeamApp, command: Command) -> Result<()> { Command::Erc20 { action } => erc20::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, Command::Update => { update::run_update(&app.overrides, app.output_mode, app.color_mode).await } diff --git a/pkg/beam-cli/src/commands/update.rs b/pkg/beam-cli/src/commands/update.rs index ddbae83..e3431fe 100644 --- a/pkg/beam-cli/src/commands/update.rs +++ b/pkg/beam-cli/src/commands/update.rs @@ -14,7 +14,7 @@ use tempfile::NamedTempFile; use std::os::unix::fs::PermissionsExt; use crate::{ - cli::Cli, + cli::{Cli, normalize_cli_args}, display::ColorMode, error::Result, output::{CommandOutput, OutputMode, with_loading}, @@ -119,8 +119,8 @@ where S: Into, { let args = args.into_iter().map(Into::into).collect::>(); - let cli = - Cli::try_parse_from(args.iter().cloned()).context("parse beam args for update restart")?; + let cli = Cli::try_parse_from(normalize_cli_args(args.iter().cloned())) + .context("parse beam args for update restart")?; if !cli.is_interactive() { return Ok(None); diff --git a/pkg/beam-cli/src/error.rs b/pkg/beam-cli/src/error.rs index e775882..8ec05e6 100644 --- a/pkg/beam-cli/src/error.rs +++ b/pkg/beam-cli/src/error.rs @@ -114,6 +114,54 @@ pub enum Error { #[error("[beam-cli] missing input for beam util {command}")] MissingUtilInput { command: String }, + #[error("[beam-cli] fetch request failed")] + FetchRequestFailed, + + #[error("[beam-cli] fetch payment required")] + FetchPaymentRequired, + + #[error("[beam-cli] fetch payment rejected")] + FetchPaymentRejected, + + #[error("[beam-cli] invalid fetch payment response")] + FetchInvalidPaymentResponse, + + #[error("[beam-cli] fetch payment retry cannot override an existing Authorization header")] + FetchPaymentAuthorizationConflict, + + #[error( + "[beam-cli] fetch payment challenge must specify a chain unless --chain or --rpc is provided" + )] + FetchPaymentChainRequired, + + #[error( + "[beam-cli] fetch payment chain mismatch: challenge requested {challenge}, but --chain selected {selected}" + )] + FetchPaymentChainMismatch { challenge: String, selected: String }, + + #[error("[beam-cli] fetch payment chain not allowed: {chain}")] + FetchPaymentChainNotAllowed { chain: String }, + + #[error("[beam-cli] fetch payment exceeds max fee")] + FetchPaymentExceedsMaxFee, + + #[error("[beam-cli] fetch payment balance too low")] + FetchPaymentInsufficientBalance, + + #[error( + "[beam-cli] fetch payment challenges require https; use --dev only for localhost or loopback HTTP fixtures: {url}" + )] + FetchPaymentRequiresHttps { url: String }, + + #[error("[beam-cli] invalid http method: {value}")] + FetchInvalidMethod { value: String }, + + #[error("[beam-cli] invalid http header: {value}")] + FetchInvalidHeader { value: String }, + + #[error("[beam-cli] payment transaction was not confirmed: {tx_hash}")] + FetchPaymentUnconfirmed { tx_hash: String }, + #[error("[beam-cli] prompt input closed while reading {label}")] PromptClosed { label: String }, diff --git a/pkg/beam-cli/src/evm.rs b/pkg/beam-cli/src/evm.rs index 19930de..25a63ab 100644 --- a/pkg/beam-cli/src/evm.rs +++ b/pkg/beam-cli/src/evm.rs @@ -35,6 +35,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, +} + pub async fn native_balance(client: &Client, address: Address) -> Result { let balance = client .eth_balance(address) @@ -111,8 +117,19 @@ pub async fn send_native( on_status: impl FnMut(TransactionStatusUpdate), cancel: impl std::future::Future, ) -> Result { - let gas = estimate_gas(client, signer.address(), to, &[], amount).await?; - let tx = fill_transaction(client, signer.address(), to, Vec::new(), amount, gas).await?; + send_native_with_gas(client, signer, to, amount, None, on_status, cancel).await +} + +pub async fn send_native_with_gas( + client: &Client, + signer: &S, + to: Address, + amount: U256, + gas: Option, + on_status: impl FnMut(TransactionStatusUpdate), + cancel: impl std::future::Future, +) -> Result { + let tx = prepare_transaction(client, signer.address(), to, Vec::new(), amount, gas).await?; submit_transaction(client, signer, tx, on_status, cancel).await } @@ -122,10 +139,20 @@ pub async fn send_function( call: FunctionCall<'_>, on_status: impl FnMut(TransactionStatusUpdate), cancel: impl std::future::Future, +) -> Result { + send_function_with_gas(client, signer, call, None, on_status, cancel).await +} + +pub async fn send_function_with_gas( + client: &Client, + signer: &S, + call: FunctionCall<'_>, + gas: Option, + on_status: impl FnMut(TransactionStatusUpdate), + cancel: impl std::future::Future, ) -> Result { let data = encode_input(call.function, call.args)?; - let gas = estimate_gas(client, signer.address(), call.contract, &data, call.value).await?; - let tx = fill_transaction( + let tx = prepare_transaction( client, signer.address(), call.contract, @@ -137,18 +164,26 @@ pub async fn send_function( submit_transaction(client, signer, tx, on_status, cancel).await } +async fn prepare_transaction( + client: &Client, + from: Address, + to: Address, + data: Vec, + value: U256, + gas: Option, +) -> Result { + let gas = resolve_transaction_gas(client, from, to, &data, value, gas).await?; + fill_transaction(client, from, to, data, value, gas).await +} + async fn fill_transaction( client: &Client, from: Address, to: Address, data: Vec, value: U256, - gas: U256, + gas: TransactionGas, ) -> Result { - let gas_price = client - .fast_gas_price() - .await - .context("fetch beam gas price")?; let nonce = client.nonce(from).await.context("fetch beam nonce")?; let chain_id = client .chain_id() @@ -159,8 +194,8 @@ async fn fill_transaction( Ok(TransactionParameters { chain_id: Some(chain_id), data: Bytes(data), - gas, - gas_price: Some(gas_price), + gas: gas.gas_limit, + gas_price: Some(gas.gas_price), nonce: Some(nonce), to: Some(to), value, @@ -168,7 +203,40 @@ async fn fill_transaction( }) } -async fn estimate_gas( +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, diff --git a/pkg/beam-cli/src/main.rs b/pkg/beam-cli/src/main.rs index 5bba48a..ea7667e 100644 --- a/pkg/beam-cli/src/main.rs +++ b/pkg/beam-cli/src/main.rs @@ -28,7 +28,7 @@ use clap::Parser; use runtime::{BeamApp, BeamPaths, ensure_root_dir}; use crate::{ - cli::{Cli, Command}, + cli::{Cli, Command, normalize_cli_args}, commands::{interactive, run}, display::error_message, error::{Error, Result}, @@ -37,7 +37,7 @@ use crate::{ #[tokio::main] async fn main() { - let cli = Cli::parse(); + let cli = Cli::parse_from(normalize_cli_args(std::env::args_os())); let color_mode = cli.color; if let Err(err) = run_cli(cli).await { diff --git a/pkg/beam-cli/src/tests.rs b/pkg/beam-cli/src/tests.rs index 5115099..fc11f0b 100644 --- a/pkg/beam-cli/src/tests.rs +++ b/pkg/beam-cli/src/tests.rs @@ -3,16 +3,33 @@ mod balance; mod call; mod chains; mod cli; +mod cli_fetch; +mod cli_metadata; mod config; mod display; mod ens; mod erc20; mod evm; +mod evm_prepared_gas; mod evm_retries; +mod fetch; +mod fetch_invalid_payment; +mod fetch_mpp; +mod fetch_output_sanitization; +mod fetch_payment; +mod fetch_payment_chain_selection; +mod fetch_redirect; +mod fetch_request; +mod fetch_retry_effective_request; +mod fetch_retry_origin; +mod fetch_test_servers; +mod fetch_x402; +mod fetch_x402_chain_aliases; mod fixtures; mod inspect; mod interactive; mod interactive_autocomplete; +mod interactive_format; mod interactive_history; mod interactive_interrupts; mod interactive_state; diff --git a/pkg/beam-cli/src/tests/cli.rs b/pkg/beam-cli/src/tests/cli.rs index 1f17b0a..44a6bbd 100644 --- a/pkg/beam-cli/src/tests/cli.rs +++ b/pkg/beam-cli/src/tests/cli.rs @@ -1,10 +1,10 @@ // lint-long-file-override allow-max-lines=300 -use clap::{CommandFactory, Parser}; +use clap::Parser; use crate::{ cli::{ BlockArgs, ChainAction, Cli, Command, Erc20Action, RpcAction, TokenAction, TxnArgs, - WalletAction, util::UtilAction, + WalletAction, normalize_cli_args, util::UtilAction, }, display::ColorMode, output::OutputMode, @@ -57,6 +57,17 @@ fn parses_global_overrides_and_balance_command() { )); } +#[test] +fn parses_format_flag_and_legacy_output_alias() { + let cli = + Cli::try_parse_from(["beam", "--format", "json", "balance"]).expect("parse format flag"); + assert_eq!(cli.output, OutputMode::Json); + + let cli = Cli::try_parse_from(normalize_cli_args(["beam", "--output", "json", "balance"])) + .expect("parse legacy output alias"); + assert_eq!(cli.output, OutputMode::Json); +} + #[test] fn parses_wallet_and_erc20_subcommands() { let wallet = Cli::try_parse_from([ @@ -272,27 +283,3 @@ fn parses_util_subcommands() { }) if args.decimals.as_deref() == Some("3") && args.value.as_deref() == Some("1.23") )); } - -#[test] -fn visible_commands_have_descriptions() { - let cli = Cli::command(); - - assert_visible_commands_have_descriptions(&cli); -} - -fn assert_visible_commands_have_descriptions(command: &clap::Command) { - for subcommand in command.get_subcommands() { - if subcommand.is_hide_set() { - continue; - } - - assert!( - subcommand.get_about().is_some() || subcommand.get_long_about().is_some(), - "subcommand `{}` under `{}` is missing a description", - subcommand.get_name(), - command.get_name(), - ); - - assert_visible_commands_have_descriptions(subcommand); - } -} diff --git a/pkg/beam-cli/src/tests/cli_fetch.rs b/pkg/beam-cli/src/tests/cli_fetch.rs new file mode 100644 index 0000000..d1ce18e --- /dev/null +++ b/pkg/beam-cli/src/tests/cli_fetch.rs @@ -0,0 +1,117 @@ +use clap::Parser; + +use crate::cli::{Cli, Command, FetchArgs}; + +#[test] +fn parses_fetch_command_flags() { + let cli = Cli::try_parse_from([ + "beam", + "fetch", + "-X", + "POST", + "-H", + "Content-Type: application/json", + "-H", + "Accept: application/json", + "-d", + "{\"hello\":\"world\"}", + "-o", + "response.json", + "-v", + "-L", + "--max-redirects", + "5", + "--max-fee", + "0.01", + "--allowed-chains", + "base,8453", + "https://api.example.com/paid", + ]) + .expect("parse fetch command"); + + assert!(matches!( + cli.command, + Some(Command::Fetch(FetchArgs { + url, + method, + headers, + data, + output_path, + verbose, + follow_redirects, + max_redirects, + max_fee, + allowed_chains, + no_pay, + dev, + .. + })) if url == "https://api.example.com/paid" + && method.as_deref() == Some("POST") + && headers == vec![ + "Content-Type: application/json".to_string(), + "Accept: application/json".to_string(), + ] + && data.as_deref() == Some("{\"hello\":\"world\"}") + && output_path.as_deref() == Some("response.json") + && verbose + && follow_redirects + && max_redirects == 5 + && max_fee.as_deref() == Some("0.01") + && allowed_chains == vec!["base".to_string(), "8453".to_string()] + && !no_pay + && !dev + )); + + let cli = Cli::try_parse_from([ + "beam", + "fetch", + "--output", + "response.bin", + "--no-pay", + "--dev", + "https://api.example.com/raw", + ]) + .expect("parse fetch output long flag"); + + assert!(matches!( + cli.command, + Some(Command::Fetch(FetchArgs { + url, + method, + output_path, + no_pay, + dev, + .. + })) if url == "https://api.example.com/raw" + && method.is_none() + && output_path.as_deref() == Some("response.bin") + && no_pay + && dev + )); +} + +#[test] +fn parses_fetch_request_body_without_implied_method_flag() { + let cli = Cli::try_parse_from([ + "beam", + "fetch", + "-d", + "hello", + "https://api.example.com/paid", + ]) + .expect("parse fetch body"); + + assert!(matches!( + cli.command, + Some(Command::Fetch(FetchArgs { method, data, .. })) + if method.is_none() && data.as_deref() == Some("hello") + )); +} + +#[test] +fn rejects_removed_fetch_pay_flag() { + let err = Cli::try_parse_from(["beam", "fetch", "--pay", "https://api.example.com/paid"]) + .expect_err("reject removed pay flag"); + + assert!(err.to_string().contains("--pay")); +} diff --git a/pkg/beam-cli/src/tests/cli_metadata.rs b/pkg/beam-cli/src/tests/cli_metadata.rs new file mode 100644 index 0000000..18c6d54 --- /dev/null +++ b/pkg/beam-cli/src/tests/cli_metadata.rs @@ -0,0 +1,27 @@ +use clap::CommandFactory; + +use crate::cli::Cli; + +#[test] +fn visible_commands_have_descriptions() { + let cli = Cli::command(); + + assert_visible_commands_have_descriptions(&cli); +} + +pub(super) fn assert_visible_commands_have_descriptions(command: &clap::Command) { + for subcommand in command.get_subcommands() { + if subcommand.is_hide_set() { + continue; + } + + assert!( + subcommand.get_about().is_some() || subcommand.get_long_about().is_some(), + "subcommand `{}` under `{}` is missing a description", + subcommand.get_name(), + command.get_name(), + ); + + assert_visible_commands_have_descriptions(subcommand); + } +} diff --git a/pkg/beam-cli/src/tests/evm_prepared_gas.rs b/pkg/beam-cli/src/tests/evm_prepared_gas.rs new file mode 100644 index 0000000..f15c85a --- /dev/null +++ b/pkg/beam-cli/src/tests/evm_prepared_gas.rs @@ -0,0 +1,177 @@ +use std::{ + future::pending, + sync::{Arc, Mutex}, +}; + +use contracts::{Address, Client, U256}; +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; +use web3::{ + ethabi::StateMutability, + types::{H256, TransactionReceipt, U64}, +}; + +use super::fixtures::read_rpc_request; +use crate::{ + abi::parse_function, + evm::{FunctionCall, TransactionGas, send_function_with_gas, send_native_with_gas}, + signer::KeySigner, + transaction::TransactionExecution, +}; + +#[tokio::test] +async fn native_transfers_with_prepared_gas_skip_reestimation() { + let (rpc_url, calls, server) = spawn_prepared_gas_rpc_server().await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let signer = KeySigner::from_slice(&[7u8; 32]).expect("create signer"); + + let outcome = send_native_with_gas( + &client, + &signer, + Address::from_low_u64_be(0xbeef), + U256::from(123u64), + Some(prepared_gas()), + |_| {}, + pending::<()>(), + ) + .await + .expect("send native transfer"); + server.abort(); + + assert!( + matches!(outcome, TransactionExecution::Confirmed(ref outcome) if outcome.status == Some(1)) + ); + assert_eq!( + rpc_methods(&calls.lock().expect("rpc calls")), + vec![ + "eth_getTransactionCount", + "eth_chainId", + "eth_sendRawTransaction", + "eth_getTransactionReceipt", + ], + ); +} + +#[tokio::test] +async fn function_calls_with_prepared_gas_skip_reestimation() { + let (rpc_url, calls, server) = spawn_prepared_gas_rpc_server().await; + let client = Client::try_new(&rpc_url, None).expect("create client"); + let signer = KeySigner::from_slice(&[7u8; 32]).expect("create signer"); + let function = parse_function("transfer(address,uint256)", StateMutability::NonPayable) + .expect("parse transfer function"); + let args = vec![ + format!("{:#x}", Address::from_low_u64_be(0xbeef)), + U256::from(123u64).to_string(), + ]; + + let outcome = send_function_with_gas( + &client, + &signer, + FunctionCall { + args: &args, + contract: Address::from_low_u64_be(0xfeed), + function: &function, + value: U256::zero(), + }, + Some(prepared_gas()), + |_| {}, + pending::<()>(), + ) + .await + .expect("send function call"); + server.abort(); + + assert!( + matches!(outcome, TransactionExecution::Confirmed(ref outcome) if outcome.status == Some(1)) + ); + assert_eq!( + rpc_methods(&calls.lock().expect("rpc calls")), + vec![ + "eth_getTransactionCount", + "eth_chainId", + "eth_sendRawTransaction", + "eth_getTransactionReceipt", + ], + ); +} + +fn prepared_gas() -> TransactionGas { + TransactionGas { + gas_limit: U256::from(36_000u64), + gas_price: U256::from(1_000_000_000u64), + } +} + +async fn spawn_prepared_gas_rpc_server() +-> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind prepared gas rpc listener"); + let address = listener.local_addr().expect("listener address"); + let calls = Arc::new(Mutex::new(Vec::new())); + let server_calls = Arc::clone(&calls); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_prepared_gas_rpc_connection(stream, Arc::clone(&server_calls)).await; + } + }); + + (format!("http://{address}"), calls, server) +} + +async fn handle_prepared_gas_rpc_connection(mut stream: TcpStream, calls: Arc>>) { + let request = read_rpc_request(&mut stream).await; + calls + .lock() + .expect("record rpc request") + .push(request.clone()); + + let body = rpc_response(&request); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +fn rpc_methods(calls: &[Value]) -> Vec<&str> { + calls + .iter() + .map(|call| call["method"].as_str().expect("rpc method")) + .collect() +} + +fn rpc_response(request: &Value) -> String { + let result = match request["method"].as_str().expect("rpc method") { + "eth_getTransactionCount" => serde_json::to_value(U256::zero()).expect("nonce"), + "eth_chainId" => serde_json::to_value(U256::one()).expect("chain id"), + "eth_sendRawTransaction" => serde_json::to_value(H256::from_low_u64_be(7)).expect("hash"), + "eth_getTransactionReceipt" => serde_json::to_value(successful_receipt()).expect("receipt"), + other => panic!("unexpected rpc method {other}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} + +fn successful_receipt() -> TransactionReceipt { + TransactionReceipt { + block_number: Some(U64::from(42)), + status: Some(U64::from(1)), + transaction_hash: H256::from_low_u64_be(7), + ..Default::default() + } +} diff --git a/pkg/beam-cli/src/tests/fetch.rs b/pkg/beam-cli/src/tests/fetch.rs new file mode 100644 index 0000000..648fc9b --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch.rs @@ -0,0 +1,274 @@ +// lint-long-file-override allow-max-lines=280 +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use mockito::mock; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde_json::Value; +use serial_test::serial; + +use super::fixtures::test_app_with_output; +use crate::{ + cli::FetchArgs, + commands::fetch::{ + self, + payment::ExecutedPayment, + protocol::{AmountValue, PaymentChallenge, parse_payment_challenge}, + }, + error::Error, + output::OutputMode, + runtime::InvocationOverrides, +}; + +fn x402_v2_fixture() -> &'static str { + include_str!("fixtures/fetch_x402_v2.json") +} + +fn x402_v1_fixture() -> &'static str { + include_str!("fixtures/fetch_x402_v1.json") +} + +fn mpp_problem_fixture() -> &'static str { + include_str!("fixtures/fetch_mpp_problem.json") +} + +fn mpp_request_fixture() -> &'static str { + include_str!("fixtures/fetch_mpp_request.json") +} + +#[test] +fn parses_x402_v2_challenge_from_payment_required_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "payment-required", + HeaderValue::from_str( + &base64::engine::general_purpose::STANDARD.encode(x402_v2_fixture().as_bytes()), + ) + .expect("payment-required header"), + ); + + let challenge = parse_payment_challenge(&headers, b"").expect("parse x402 challenge"); + + let Some(PaymentChallenge::X402(challenge)) = challenge else { + panic!("expected x402 challenge"); + }; + + assert_eq!(challenge.version, 2); + assert_eq!(challenge.offers.len(), 1); + assert!(matches!( + challenge.offers[0].amount, + AmountValue::Atomic(ref value) if value == "10000" + )); + assert_eq!(challenge.offers[0].network, "eip155:8453"); +} + +#[test] +fn parses_x402_v1_challenge_from_body() { + let challenge = parse_payment_challenge(&HeaderMap::new(), x402_v1_fixture().as_bytes()) + .expect("parse x402 body challenge"); + + let Some(PaymentChallenge::X402(challenge)) = challenge else { + panic!("expected x402 challenge"); + }; + + assert_eq!(challenge.version, 1); + assert_eq!(challenge.offers[0].asset, "native"); + assert!(matches!( + challenge.offers[0].amount, + AmountValue::Atomic(ref value) if value == "420000000000000" + )); +} + +#[test] +fn parses_mpp_challenge_from_problem_and_www_authenticate_header() { + let mut headers = HeaderMap::new(); + let request = URL_SAFE_NO_PAD.encode(mpp_request_fixture().as_bytes()); + let authenticate = format!( + "Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + ); + headers.insert( + "www-authenticate", + HeaderValue::from_str(&authenticate).expect("www-authenticate"), + ); + + let challenge = parse_payment_challenge(&headers, mpp_problem_fixture().as_bytes()) + .expect("parse mpp challenge"); + + let Some(PaymentChallenge::Mpp(challenge)) = challenge else { + panic!("expected mpp challenge"); + }; + + assert_eq!(challenge.problem.challenge_id, "challenge_123"); + assert_eq!( + challenge.auth.as_ref().expect("auth").method, + "tempo.charge" + ); + assert_eq!( + challenge.request.currency, + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + ); + assert!(matches!( + challenge.request.amount, + AmountValue::Human(ref value) if value == "0.01" + )); +} + +#[test] +fn builds_x402_retry_header_payload() { + let mut headers = HeaderMap::new(); + headers.insert( + "payment-required", + HeaderValue::from_str( + &base64::engine::general_purpose::STANDARD.encode(x402_v2_fixture().as_bytes()), + ) + .expect("payment-required header"), + ); + + let challenge = parse_payment_challenge(&headers, b"") + .expect("parse x402 challenge") + .expect("x402 challenge"); + let PaymentChallenge::X402(challenge) = challenge else { + panic!("expected x402 challenge"); + }; + let offer = challenge.offers.first().expect("x402 offer"); + let executed = ExecutedPayment { + accepted: offer.raw.clone(), + network: offer.network.clone(), + proof: serde_json::json!({ "txHash": "0xabc123" }), + scheme: offer.scheme.clone(), + source: None, + }; + + let header = PaymentChallenge::X402(challenge) + .retry_header(&executed) + .expect("build x402 retry header"); + let encoded = header.value.to_str().expect("header value"); + let payload = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("decode x402 payload"); + let payload = serde_json::from_slice::(&payload).expect("parse x402 payload"); + + assert_eq!(header.name.as_str(), "payment-signature"); + assert_eq!(payload["x402Version"], 2); + assert_eq!(payload["payload"]["txHash"], "0xabc123"); +} + +#[test] +fn builds_mpp_authorization_header() { + let mut headers = HeaderMap::new(); + let request = URL_SAFE_NO_PAD.encode(mpp_request_fixture().as_bytes()); + let authenticate = format!( + "Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + ); + headers.insert( + "www-authenticate", + HeaderValue::from_str(&authenticate).expect("www-authenticate"), + ); + + let challenge = parse_payment_challenge(&headers, mpp_problem_fixture().as_bytes()) + .expect("parse mpp challenge") + .expect("mpp challenge"); + let PaymentChallenge::Mpp(challenge) = challenge else { + panic!("expected mpp challenge"); + }; + + let header = PaymentChallenge::Mpp(challenge) + .retry_header(&ExecutedPayment { + accepted: Value::Null, + network: "eip155:8453".to_string(), + proof: serde_json::json!({ "hash": "0xabc123", "type": "hash" }), + scheme: "tempo.charge".to_string(), + source: Some( + "did:pkh:eip155:8453:0x4444444444444444444444444444444444444444".to_string(), + ), + }) + .expect("build mpp auth header"); + let value = header.value.to_str().expect("authorization value"); + let encoded = value.strip_prefix("Payment ").expect("payment prefix"); + let payload = URL_SAFE_NO_PAD + .decode(encoded) + .expect("decode mpp credential"); + let payload = serde_json::from_slice::(&payload).expect("parse mpp credential"); + + assert_eq!(header.name.as_str(), "authorization"); + assert_eq!(payload["payload"]["hash"], "0xabc123"); + assert_eq!( + payload["source"], + "did:pkh:eip155:8453:0x4444444444444444444444444444444444444444" + ); +} + +#[tokio::test] +#[serial] +async fn fetch_writes_response_to_output_file() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let output = tempfile::NamedTempFile::new().expect("create output file"); + let output_path = output.path().to_string_lossy().to_string(); + let body = "paid-content".repeat(8 * 1024); + let _endpoint = mock("GET", "/paid") + .with_status(200) + .with_body(body.clone()) + .create(); + + fetch::run( + &app, + FetchArgs { + url: format!("{}/paid", mockito::server_url()), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: Some(output_path.clone()), + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev: false, + }, + ) + .await + .expect("fetch output file"); + + let output = std::fs::read_to_string(output_path).expect("read output file"); + assert_eq!(output, body); +} + +#[tokio::test] +#[serial] +async fn fetch_returns_payment_required_when_no_pay_is_set() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let _endpoint = mock("GET", "/paid") + .with_status(402) + .with_header("content-type", "application/json") + .with_body(x402_v1_fixture()) + .create(); + + let err = fetch::run( + &app, + FetchArgs { + url: format!("{}/paid", mockito::server_url()), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: true, + dev: true, + }, + ) + .await + .expect_err("require no-pay failure"); + + assert!(matches!(err, Error::FetchPaymentRequired)); +} diff --git a/pkg/beam-cli/src/tests/fetch_invalid_payment.rs b/pkg/beam-cli/src/tests/fetch_invalid_payment.rs new file mode 100644 index 0000000..936c7ba --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_invalid_payment.rs @@ -0,0 +1,95 @@ +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use mockito::mock; +use serial_test::serial; + +use super::fixtures::test_app_with_output; +use crate::{ + cli::FetchArgs, commands::fetch, error::Error, output::OutputMode, runtime::InvocationOverrides, +}; + +fn mpp_problem_fixture() -> &'static str { + include_str!("fixtures/fetch_mpp_problem.json") +} + +fn mpp_request_fixture() -> &'static str { + include_str!("fixtures/fetch_mpp_request.json") +} + +#[tokio::test] +#[serial] +async fn fetch_rejects_authless_mpp_problem_when_no_pay_is_set() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let _endpoint = mock("GET", "/paid") + .with_status(402) + .with_header("content-type", "application/json") + .with_body(mpp_problem_fixture()) + .create(); + + let err = fetch::run( + &app, + FetchArgs { + url: format!("{}/paid", mockito::server_url()), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: true, + dev: true, + }, + ) + .await + .expect_err("reject malformed mpp response"); + + assert!(matches!(err, Error::FetchInvalidPaymentResponse)); +} + +#[tokio::test] +#[serial] +async fn fetch_rejects_mpp_problem_with_mismatched_auth_challenge_id() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let request = URL_SAFE_NO_PAD.encode(mpp_request_fixture().as_bytes()); + let authenticate = format!( + "Payment id=\"challenge_456\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + ); + let _endpoint = mock("GET", "/paid") + .with_status(402) + .with_header("content-type", "application/json") + .with_header("www-authenticate", &authenticate) + .with_body(mpp_problem_fixture()) + .create(); + + let err = fetch::run( + &app, + FetchArgs { + url: format!("{}/paid", mockito::server_url()), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: true, + dev: true, + }, + ) + .await + .expect_err("reject mismatched mpp challenge ids"); + + assert!(matches!(err, Error::FetchInvalidPaymentResponse)); +} diff --git a/pkg/beam-cli/src/tests/fetch_mpp.rs b/pkg/beam-cli/src/tests/fetch_mpp.rs new file mode 100644 index 0000000..e38170c --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_mpp.rs @@ -0,0 +1,405 @@ +// lint-long-file-override allow-max-lines=500 +use std::sync::{Arc, Mutex}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use contracts::U256; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde_json::{Value, json}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpListener, TcpStream}, +}; + +use super::fixtures::{read_rpc_request, test_app}; +use crate::{ + commands::fetch::{ + payment::{PaymentAssetKind, PreparedPayment, prepare_mpp_payment}, + protocol::{MppChallenge, PaymentChallenge, parse_payment_challenge}, + }, + error::Error, + evm::parse_units, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides}, +}; + +const TEST_WALLET_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +fn mpp_problem_fixture() -> &'static str { + include_str!("fixtures/fetch_mpp_problem.json") +} + +fn mpp_request_fixture() -> &'static str { + include_str!("fixtures/fetch_mpp_request.json") +} + +fn mpp_request_without_chain_fixture(currency: &str, decimals: Option) -> String { + let decimals = decimals + .map(|value| format!(",\n \"decimals\": {value}")) + .unwrap_or_default(); + + format!( + r#"{{ + "amount": "0.01", + "currency": "{currency}", + "recipient": "0x3333333333333333333333333333333333333333"{decimals}, + "description": "Tempo test charge" +}}"# + ) +} + +fn mpp_native_request_fixture() -> &'static str { + r#"{ + "amount": "0.01", + "currency": "native", + "recipient": "0x3333333333333333333333333333333333333333", + "chainId": 8453, + "description": "Tempo test charge" +}"# +} + +fn mpp_unknown_token_request_fixture() -> &'static str { + r#"{ + "amount": "0.01", + "currency": "0x0000000000000000000000000000000000000bee", + "decimals": 18, + "recipient": "0x3333333333333333333333333333333333333333", + "chainId": 8453, + "description": "Tempo test charge" +}"# +} + +fn parse_mpp_challenge_with_header(authenticate: String) -> MppChallenge { + let mut headers = HeaderMap::new(); + headers.insert( + "www-authenticate", + HeaderValue::from_str(&authenticate).expect("www-authenticate"), + ); + + let challenge = parse_payment_challenge(&headers, mpp_problem_fixture().as_bytes()) + .expect("parse mpp challenge") + .expect("mpp challenge"); + + let PaymentChallenge::Mpp(challenge) = challenge else { + panic!("expected mpp challenge"); + }; + + *challenge +} + +async fn seed_default_wallet(app: &BeamApp) { + app.keystore_store + .set(KeyStore { + wallets: vec![StoredWallet { + address: TEST_WALLET_ADDRESS.to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "alice".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }], + }) + .await + .expect("persist keystore"); + + app.config_store + .update(|config| config.default_wallet = Some("alice".to_string())) + .await + .expect("persist default wallet"); +} + +async fn prepare_rpc_only_mpp_payment(request_body: &str, chain_id: u64) -> PreparedPayment { + let (rpc_url, server) = spawn_payment_prepare_rpc_server(chain_id).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + rpc: Some(rpc_url), + ..InvocationOverrides::default() + }) + .await; + seed_default_wallet(&app).await; + + let request = URL_SAFE_NO_PAD.encode(request_body.as_bytes()); + let challenge = parse_mpp_challenge_with_header(format!( + "Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + )); + + let payment = prepare_mpp_payment(&app, &challenge) + .await + .expect("prepare mpp payment"); + server.abort(); + payment +} + +async fn spawn_payment_prepare_rpc_server(chain_id: u64) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind payment prepare rpc listener"); + let address = listener.local_addr().expect("listener address"); + + let server = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("accept rpc connection"); + handle_payment_prepare_rpc_connection(stream, chain_id).await; + } + }); + + (format!("http://{address}"), server) +} + +async fn handle_payment_prepare_rpc_connection(mut stream: TcpStream, chain_id: u64) { + let request = read_rpc_request(&mut stream).await; + let body = payment_prepare_rpc_response(&request, chain_id); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); +} + +fn payment_prepare_rpc_response(request: &Value, chain_id: u64) -> String { + let result = match request["method"].as_str().expect("rpc method") { + "eth_chainId" => serde_json::to_value(U256::from(chain_id)).expect("chain id"), + "eth_estimateGas" => serde_json::to_value(U256::from(21_000u64)).expect("estimate gas"), + "eth_gasPrice" => serde_json::to_value(U256::from(1_000_000_000u64)).expect("gas price"), + other => panic!("unexpected rpc method {other}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} + +async fn spawn_token_prepare_rpc_server( + chain_id: u64, + decimals: u8, +) -> (String, Arc>>, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind token prepare rpc listener"); + let address = listener.local_addr().expect("listener address"); + let methods = Arc::new(Mutex::new(Vec::new())); + let server_methods = Arc::clone(&methods); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept rpc connection"); + let request = read_rpc_request(&mut stream).await; + server_methods + .lock() + .expect("record rpc method") + .push(request["method"].as_str().expect("rpc method").to_string()); + let body = token_prepare_rpc_response(&request, chain_id, decimals); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); + } + }); + + (format!("http://{address}"), methods, server) +} + +fn token_prepare_rpc_response(request: &Value, chain_id: u64, decimals: u8) -> String { + let result = match request["method"].as_str().expect("rpc method") { + "eth_call" => Value::String(format!("0x{decimals:064x}")), + "eth_chainId" => serde_json::to_value(U256::from(chain_id)).expect("chain id"), + "eth_estimateGas" => serde_json::to_value(U256::from(65_000u64)).expect("estimate gas"), + "eth_gasPrice" => serde_json::to_value(U256::from(1_000_000_000u64)).expect("gas price"), + other => panic!("unexpected rpc method {other}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} + +#[test] +fn parses_mpp_challenge_from_lowercase_payment_scheme() { + let request = URL_SAFE_NO_PAD.encode(mpp_request_fixture().as_bytes()); + let challenge = parse_mpp_challenge_with_header(format!( + "payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + )); + + assert_eq!(challenge.problem.challenge_id, "challenge_123"); + assert_eq!( + challenge.auth.as_ref().expect("auth").method, + "tempo.charge" + ); +} + +#[test] +fn parses_payment_challenge_from_multi_scheme_www_authenticate_header() { + let request = URL_SAFE_NO_PAD.encode(mpp_request_fixture().as_bytes()); + let challenge = parse_mpp_challenge_with_header(format!( + "Basic realm=\"api.example.com\", Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + )); + + assert_eq!(challenge.problem.challenge_id, "challenge_123"); + assert_eq!( + challenge.auth.as_ref().expect("auth").realm, + "api.example.com" + ); +} + +#[tokio::test] +async fn prepare_mpp_payment_requires_explicit_chain_when_challenge_omits_it() { + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + let request = URL_SAFE_NO_PAD.encode( + mpp_request_without_chain_fixture("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", Some(6)) + .as_bytes(), + ); + let challenge = parse_mpp_challenge_with_header(format!( + "Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + )); + + let err = prepare_mpp_payment(&app, &challenge) + .await + .expect_err("require explicit chain"); + + assert!(matches!(err, Error::FetchPaymentChainRequired)); +} + +#[tokio::test] +async fn prepare_mpp_payment_rejects_explicit_chain_that_disagrees_with_challenge() { + let (rpc_url, server) = spawn_payment_prepare_rpc_server(8453).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + chain: Some("ethereum".to_string()), + rpc: Some(rpc_url), + ..InvocationOverrides::default() + }) + .await; + seed_default_wallet(&app).await; + + let request = URL_SAFE_NO_PAD.encode(mpp_native_request_fixture().as_bytes()); + let challenge = parse_mpp_challenge_with_header(format!( + "Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + )); + + let err = prepare_mpp_payment(&app, &challenge) + .await + .expect_err("reject mismatched explicit chain"); + server.abort(); + + assert!(matches!( + err, + Error::FetchPaymentChainMismatch { challenge, selected } + if challenge == "Base (8453)" && selected == "Ethereum (1)" + )); +} + +#[tokio::test] +async fn prepare_mpp_payment_accepts_matching_explicit_chain() { + let (rpc_url, server) = spawn_payment_prepare_rpc_server(8453).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + chain: Some("base".to_string()), + rpc: Some(rpc_url), + ..InvocationOverrides::default() + }) + .await; + seed_default_wallet(&app).await; + + let request = URL_SAFE_NO_PAD.encode(mpp_native_request_fixture().as_bytes()); + let challenge = parse_mpp_challenge_with_header(format!( + "Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + )); + + let payment = prepare_mpp_payment(&app, &challenge) + .await + .expect("prepare mpp payment"); + server.abort(); + + assert_eq!(payment.chain.chain_id, 8453); + assert_eq!(payment.chain.key, "base"); + assert_eq!(payment.network, "eip155:8453"); + assert_eq!( + payment.selected_chain.as_ref().map(|chain| chain.chain_id), + Some(8453) + ); + assert_eq!( + payment + .selected_chain + .as_ref() + .map(|chain| chain.key.as_str()), + Some("base") + ); +} + +#[tokio::test] +async fn prepare_mpp_payment_rehydrates_known_chain_metadata_for_token_labels_over_rpc_only() { + let payment = + prepare_rpc_only_mpp_payment(&mpp_request_without_chain_fixture("USDC", None), 137).await; + + assert_eq!(payment.chain.chain_id, 137); + assert_eq!(payment.chain.key, "polygon"); + assert_eq!(payment.chain.native_symbol, "MATIC"); + assert_eq!(payment.asset.decimals, 6); + assert_eq!(payment.asset.label, "USDC"); + assert!(matches!( + payment.asset.kind, + PaymentAssetKind::Erc20(address) + if format!("{address:#x}") == "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" + )); +} + +#[tokio::test] +async fn prepare_mpp_payment_rehydrates_known_chain_native_symbol_over_rpc_only() { + let payment = + prepare_rpc_only_mpp_payment(&mpp_request_without_chain_fixture("MATIC", None), 137).await; + + assert_eq!(payment.chain.chain_id, 137); + assert_eq!(payment.chain.key, "polygon"); + assert_eq!(payment.chain.native_symbol, "MATIC"); + assert_eq!(payment.asset.label, "MATIC"); + assert!(matches!(payment.asset.kind, PaymentAssetKind::Native)); + + let confirmation = payment.confirmation_message("MPP"); + assert!(confirmation.contains("Network: Polygon (137)")); + assert!(confirmation.contains("Estimated gas:")); + assert!(confirmation.contains("MATIC")); +} + +#[tokio::test] +async fn prepare_mpp_payment_fetches_unknown_token_decimals_from_contract() { + let (rpc_url, methods, server) = spawn_token_prepare_rpc_server(8453, 6).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + rpc: Some(rpc_url), + ..InvocationOverrides::default() + }) + .await; + seed_default_wallet(&app).await; + + let request = URL_SAFE_NO_PAD.encode(mpp_unknown_token_request_fixture().as_bytes()); + let challenge = parse_mpp_challenge_with_header(format!( + "Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + )); + + let payment = prepare_mpp_payment(&app, &challenge) + .await + .expect("prepare mpp payment"); + server.abort(); + + assert_eq!(payment.asset.decimals, 6); + assert_eq!( + payment.amount, + parse_units("0.01", 6).expect("scale token amount with on-chain decimals") + ); + assert!( + methods + .lock() + .expect("rpc methods") + .iter() + .any(|method| method == "eth_call") + ); +} diff --git a/pkg/beam-cli/src/tests/fetch_output_sanitization.rs b/pkg/beam-cli/src/tests/fetch_output_sanitization.rs new file mode 100644 index 0000000..f34a619 --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_output_sanitization.rs @@ -0,0 +1,172 @@ +use std::io::Cursor; + +use contracts::{Address, Client, U256}; +use serde_json::Value; + +use crate::{ + chains::BeamChains, + cli::FetchArgs, + commands::fetch::{ + payment::{ + GasEstimate, PaymentAsset, PaymentAssetKind, PaymentChain, PreparedPayment, + approve_payment_with, + }, + protocol::{ + AmountValue, MppAuthChallenge, MppChallenge, MppPaymentRequest, MppProblem, + PaymentChallenge, X402Challenge, X402Offer, + }, + }, +}; + +#[test] +fn x402_describe_sanitizes_human_facing_offer_fields() { + let challenge = PaymentChallenge::X402(X402Challenge { + offers: vec![X402Offer { + amount: AmountValue::Atomic("1000\n\x1b[31m".to_string()), + asset: "USDC\t\x1b[31m".to_string(), + network: "eip155:8453\r\x1b[31m".to_string(), + pay_to: "0xabc\x1b[31m".to_string(), + raw: Value::Null, + scheme: "exact\n\x1b[31m".to_string(), + }], + resource: None, + version: 2, + }); + + assert_eq!( + challenge.describe(), + "Payment required via x402\nOffers: 1\n- 1000 ?[31m USDC ?[31m on eip155:8453 ?[31m to 0xabc?[31m (exact ?[31m)" + ); +} + +#[test] +fn mpp_describe_sanitizes_human_facing_problem_fields() { + let challenge = PaymentChallenge::Mpp(Box::new(MppChallenge { + auth: Some(MppAuthChallenge { + description: None, + digest: None, + expires: None, + id: "challenge_123".to_string(), + intent: "charge".to_string(), + method: "tempo.charge\n\x1b[31m".to_string(), + opaque: None, + realm: "api.example.com".to_string(), + request: "request".to_string(), + }), + problem: MppProblem { + challenge_id: "challenge_123\n\x1b[31m".to_string(), + detail: Some("Detail\t\x1b[31m".to_string()), + title: Some("Title\r\x1b[31m".to_string()), + }, + request: MppPaymentRequest { + amount: AmountValue::Human("0.01\n\x1b[31m".to_string()), + chain_id: Some(8453), + currency: "USDC\t\x1b[31m".to_string(), + description: Some("Invoice\n\x1b[31m".to_string()), + recipient: "0x333\r\x1b[31m".to_string(), + }, + })); + + assert_eq!( + challenge.describe(), + "Payment required via MPP\nChallenge: challenge_123 ?[31m\nTitle: Title ?[31m\nDetail: Detail ?[31m\nMethod: tempo.charge ?[31m 0.01 ?[31m USDC ?[31m 0x333 ?[31m" + ); +} + +#[test] +fn confirmation_message_sanitizes_payment_details() { + let mut payment = payment_fixture(); + payment.asset.label = "USDC\n\x1b[31m".to_string(); + payment.chain.display_name = "Base\t\x1b[31m".to_string(); + payment.chain.native_symbol = "ETH\r\x1b[31m".to_string(); + payment.description = Some("Invoice\n\x1b[31m".to_string()); + + let confirmation = payment.confirmation_message("MPP"); + + assert!(!confirmation.contains('\x1b')); + assert!(confirmation.contains("Amount: 1 USDC ?[31m")); + assert!(confirmation.contains("Network: Base ?[31m (8453)")); + assert!(confirmation.contains("Estimated gas: 0.001 ETH ?[31m")); + assert!(confirmation.contains("Description: Invoice ?[31m")); +} + +#[test] +fn cross_chain_prompt_sanitizes_chain_summaries() { + let mut payment = payment_fixture(); + payment.chain = payment_chain(8453, "Base\n\x1b[31m", "base"); + payment.selected_chain = Some(payment_chain(1, "Ethereum\t\x1b[31m", "ethereum")); + + let mut input = Cursor::new("yes\n"); + let mut output = Vec::new(); + + approve_payment_with( + &fetch_args(Some("2.0")), + &payment, + &BeamChains::default(), + &mut input, + &mut output, + ) + .expect("approve sanitized prompt"); + + let prompt = String::from_utf8(output).expect("prompt utf8"); + assert!(!prompt.contains('\x1b')); + assert!(prompt.contains("Base ?[31m (8453)")); + assert!(prompt.contains("Ethereum ?[31m (1)")); +} + +fn fetch_args(max_fee: Option<&str>) -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: max_fee.map(ToString::to_string), + allowed_chains: Vec::new(), + no_pay: false, + dev: false, + } +} + +fn payment_fixture() -> PreparedPayment { + PreparedPayment { + accepted: Value::Null, + amount: U256::from(1u64), + amount_display: "1".to_string(), + asset: PaymentAsset { + decimals: 6, + kind: PaymentAssetKind::Erc20(Address::from_low_u64_be(0xfeed)), + label: "USDC".to_string(), + }, + asset_id: "asset".to_string(), + chain: payment_chain(8453, "Base", "base"), + client: Client::new("http://localhost:8545", None), + description: None, + gas: GasEstimate { + fee: U256::exp10(15), + gas_limit: U256::from(21_000u64), + gas_price: U256::from(1_000_000_000u64), + }, + network: "eip155:8453".to_string(), + payer: Address::from_low_u64_be(1), + recipient: Address::from_low_u64_be(2), + selected_chain: Some(payment_chain(8453, "Base", "base")), + scheme: "exact".to_string(), + } +} + +fn payment_chain(chain_id: u64, display_name: &str, key: &str) -> PaymentChain { + PaymentChain { + aliases: Vec::new(), + chain_id, + display_name: display_name.to_string(), + key: key.to_string(), + native_symbol: "ETH".to_string(), + } +} diff --git a/pkg/beam-cli/src/tests/fetch_payment.rs b/pkg/beam-cli/src/tests/fetch_payment.rs new file mode 100644 index 0000000..173497f --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_payment.rs @@ -0,0 +1,252 @@ +// lint-long-file-override allow-max-lines=300 +use std::io::Cursor; + +use contracts::{Address, Client, U256}; +use serde_json::Value; + +use crate::{ + chains::BeamChains, + cli::FetchArgs, + commands::fetch::payment::{ + GasEstimate, PaymentAsset, PaymentAssetKind, PaymentChain, PreparedPayment, + approve_payment, approve_payment_with, + }, + error::Error, + evm::parse_units, +}; + +#[test] +fn approve_payment_rejects_native_total_above_max_fee() { + let payment = payment_fixture( + PaymentAsset { + decimals: 18, + kind: PaymentAssetKind::Native, + label: "ETH".to_string(), + }, + parse_units("0.95", 18).expect("native amount"), + parse_units("0.10", 18).expect("gas fee"), + ); + + let err = approve_payment( + &fetch_args(Some("1.0"), &[]), + &payment, + &BeamChains::default(), + ) + .expect_err("reject max fee"); + + assert!(matches!(err, Error::FetchPaymentExceedsMaxFee)); +} + +#[test] +fn approve_payment_accepts_native_total_within_max_fee() { + let payment = payment_fixture( + PaymentAsset { + decimals: 18, + kind: PaymentAssetKind::Native, + label: "ETH".to_string(), + }, + parse_units("0.90", 18).expect("native amount"), + parse_units("0.10", 18).expect("gas fee"), + ); + + approve_payment( + &fetch_args(Some("1.0"), &[]), + &payment, + &BeamChains::default(), + ) + .expect("approve max fee"); +} + +#[test] +fn approve_payment_rejects_token_payment_when_gas_exceeds_max_fee() { + let payment = payment_fixture( + PaymentAsset { + decimals: 6, + kind: PaymentAssetKind::Erc20(Address::from_low_u64_be(0xfeed)), + label: "USDC".to_string(), + }, + parse_units("0.01", 6).expect("token amount"), + parse_units("0.02", 18).expect("gas fee"), + ); + + let err = approve_payment( + &fetch_args(Some("0.01"), &[]), + &payment, + &BeamChains::default(), + ) + .expect_err("reject max fee"); + + assert!(matches!(err, Error::FetchPaymentExceedsMaxFee)); +} + +#[test] +fn approve_payment_accepts_token_payment_when_amount_and_gas_fit_cap() { + let payment = payment_fixture( + PaymentAsset { + decimals: 6, + kind: PaymentAssetKind::Erc20(Address::from_low_u64_be(0xfeed)), + label: "USDC".to_string(), + }, + parse_units("0.01", 6).expect("token amount"), + parse_units("0.001", 18).expect("gas fee"), + ); + + approve_payment( + &fetch_args(Some("0.01"), &[]), + &payment, + &BeamChains::default(), + ) + .expect("approve max fee"); +} + +#[test] +fn approve_payment_rejects_chain_outside_allowlist() { + let mut payment = payment_fixture( + PaymentAsset { + decimals: 18, + kind: PaymentAssetKind::Native, + label: "ETH".to_string(), + }, + parse_units("0.10", 18).expect("native amount"), + parse_units("0.01", 18).expect("gas fee"), + ); + payment.chain = payment_chain(1, "Ethereum", "ethereum", &["mainnet"]); + payment.selected_chain = Some(payment_chain(8453, "Base", "base", &[])); + + let err = approve_payment( + &fetch_args(Some("1.0"), &["base"]), + &payment, + &BeamChains::default(), + ) + .expect_err("reject disallowed chain"); + + assert!(matches!(err, Error::FetchPaymentChainNotAllowed { .. })); +} + +#[test] +fn approve_payment_accepts_chain_inside_allowlist() { + let mut payment = payment_fixture( + PaymentAsset { + decimals: 18, + kind: PaymentAssetKind::Native, + label: "ETH".to_string(), + }, + parse_units("0.10", 18).expect("native amount"), + parse_units("0.01", 18).expect("gas fee"), + ); + payment.chain = payment_chain(1, "Ethereum", "ethereum", &["mainnet"]); + payment.selected_chain = Some(payment_chain(8453, "Base", "base", &[])); + + approve_payment( + &fetch_args(Some("1.0"), &["ethereum"]), + &payment, + &BeamChains::default(), + ) + .expect("approve allowed chain"); +} + +#[test] +fn approve_payment_accepts_chain_selector_with_find_chain_normalization() { + let mut payment = payment_fixture( + PaymentAsset { + decimals: 18, + kind: PaymentAssetKind::Native, + label: "PUSD".to_string(), + }, + parse_units("0.10", 18).expect("native amount"), + parse_units("0.01", 18).expect("gas fee"), + ); + payment.chain = payment_chain(7297, "Payy Dev", "payy-dev", &["payydev"]); + payment.selected_chain = Some(payment_chain(8453, "Base", "base", &[])); + + approve_payment( + &fetch_args(Some("1.0"), &["payy_dev"]), + &payment, + &BeamChains::default(), + ) + .expect("approve normalized selector"); +} + +#[test] +fn approve_payment_prompts_before_accepting_cross_chain_request() { + let mut payment = payment_fixture( + PaymentAsset { + decimals: 18, + kind: PaymentAssetKind::Native, + label: "ETH".to_string(), + }, + parse_units("0.10", 18).expect("native amount"), + parse_units("0.01", 18).expect("gas fee"), + ); + payment.chain = payment_chain(1, "Ethereum", "ethereum", &["mainnet"]); + payment.selected_chain = Some(payment_chain(8453, "Base", "base", &[])); + + let mut input = Cursor::new("yes\n"); + let mut output = Vec::new(); + + approve_payment_with( + &fetch_args(Some("1.0"), &[]), + &payment, + &BeamChains::default(), + &mut input, + &mut output, + ) + .expect("approve prompted chain"); + + let prompt = String::from_utf8(output).expect("prompt utf8"); + assert!(prompt.contains("Ethereum (1)")); + assert!(prompt.contains("Base (8453)")); +} + +fn fetch_args(max_fee: Option<&str>, allowed_chains: &[&str]) -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: max_fee.map(ToString::to_string), + allowed_chains: allowed_chains.iter().map(ToString::to_string).collect(), + no_pay: false, + dev: false, + } +} + +fn payment_fixture(asset: PaymentAsset, amount: U256, gas_fee: U256) -> PreparedPayment { + PreparedPayment { + accepted: Value::Null, + amount, + amount_display: "0".to_string(), + asset, + asset_id: "asset".to_string(), + chain: payment_chain(8453, "Base", "base", &["base-mainnet"]), + client: Client::new("http://localhost:8545", None), + description: None, + gas: GasEstimate { + fee: gas_fee, + gas_limit: U256::from(21_000u64), + gas_price: U256::from(1u64), + }, + network: "eip155:8453".to_string(), + payer: Address::from_low_u64_be(1), + recipient: Address::from_low_u64_be(2), + selected_chain: Some(payment_chain(8453, "Base", "base", &["base-mainnet"])), + scheme: "exact".to_string(), + } +} + +fn payment_chain(chain_id: u64, display_name: &str, key: &str, aliases: &[&str]) -> PaymentChain { + PaymentChain { + aliases: aliases.iter().map(ToString::to_string).collect(), + chain_id, + display_name: display_name.to_string(), + key: key.to_string(), + native_symbol: "ETH".to_string(), + } +} diff --git a/pkg/beam-cli/src/tests/fetch_payment_chain_selection.rs b/pkg/beam-cli/src/tests/fetch_payment_chain_selection.rs new file mode 100644 index 0000000..8f17a15 --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_payment_chain_selection.rs @@ -0,0 +1,247 @@ +// lint-long-file-override allow-max-lines=260 +use contracts::U256; +use serde_json::{Value, json}; +use tokio::{io::AsyncWriteExt, net::TcpListener}; + +use super::fixtures::{read_rpc_request, test_app}; +use crate::{ + chains::{BeamChains, ConfiguredChain}, + cli::FetchArgs, + commands::fetch::{ + payment::{prepare_mpp_payment, prepare_x402_payment}, + protocol::{ + AmountValue, MppAuthChallenge, MppChallenge, MppPaymentRequest, MppProblem, + X402Challenge, X402Offer, + }, + }, + config::ChainRpcConfig, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides}, +}; + +const BASE_CHAIN_ID: u64 = 8453; +const STALE_CHAIN_ID: u64 = 31_337; +const RECIPIENT_ADDRESS: &str = "0x3333333333333333333333333333333333333333"; +const STALE_CHAIN_KEY: &str = "forgotten-chain"; +const TEST_WALLET_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + +#[tokio::test] +async fn prepare_x402_payment_ignores_stale_default_chain_rpc_when_offer_chain_is_resolvable() { + let (base_rpc, server) = spawn_payment_prepare_rpc_server(BASE_CHAIN_ID, U256::exp10(18)).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_stale_default_chain(&app).await; + set_rpc_config(&app, "base", &base_rpc).await; + + let payment = prepare_x402_payment(&app, &fetch_args(), &x402_challenge(BASE_CHAIN_ID)) + .await + .expect("prepare x402 payment with stale default chain"); + server.abort(); + + assert_eq!(payment.chain.key, "base"); + assert_eq!( + payment + .selected_chain + .as_ref() + .map(|chain| chain.key.as_str()), + Some(STALE_CHAIN_KEY) + ); + assert_eq!( + payment.selected_chain.as_ref().map(|chain| chain.chain_id), + Some(STALE_CHAIN_ID) + ); +} + +#[tokio::test] +async fn prepare_mpp_payment_ignores_stale_default_chain_rpc_when_request_chain_is_resolvable() { + let (base_rpc, server) = spawn_payment_prepare_rpc_server(BASE_CHAIN_ID, U256::exp10(18)).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_stale_default_chain(&app).await; + set_rpc_config(&app, "base", &base_rpc).await; + + let payment = prepare_mpp_payment(&app, &mpp_challenge(BASE_CHAIN_ID)) + .await + .expect("prepare mpp payment with stale default chain"); + server.abort(); + + assert_eq!(payment.chain.key, "base"); + assert_eq!( + payment + .selected_chain + .as_ref() + .map(|chain| chain.key.as_str()), + Some(STALE_CHAIN_KEY) + ); + assert_eq!( + payment.selected_chain.as_ref().map(|chain| chain.chain_id), + Some(STALE_CHAIN_ID) + ); +} + +fn fetch_args() -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev: false, + } +} + +fn x402_challenge(chain_id: u64) -> X402Challenge { + X402Challenge { + offers: vec![X402Offer { + amount: AmountValue::Atomic("100000000000000000".to_string()), + asset: "native".to_string(), + network: format!("eip155:{chain_id}"), + pay_to: RECIPIENT_ADDRESS.to_string(), + raw: Value::Null, + scheme: "exact".to_string(), + }], + resource: None, + version: 2, + } +} + +fn mpp_challenge(chain_id: u64) -> MppChallenge { + MppChallenge { + auth: Some(MppAuthChallenge { + description: None, + digest: None, + expires: None, + id: "challenge_123".to_string(), + intent: "charge".to_string(), + method: "tempo.charge".to_string(), + opaque: None, + realm: "api.example.com".to_string(), + request: "request".to_string(), + }), + problem: MppProblem { + challenge_id: "challenge_123".to_string(), + detail: Some("Tempo test charge".to_string()), + title: None, + }, + request: MppPaymentRequest { + amount: AmountValue::Human("0.01".to_string()), + chain_id: Some(chain_id), + currency: "native".to_string(), + description: Some("Tempo test charge".to_string()), + recipient: RECIPIENT_ADDRESS.to_string(), + }, + } +} + +async fn seed_default_wallet(app: &BeamApp) { + app.keystore_store + .set(KeyStore { + wallets: vec![StoredWallet { + address: TEST_WALLET_ADDRESS.to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "alice".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }], + }) + .await + .expect("persist keystore"); + + app.config_store + .update(|config| config.default_wallet = Some("alice".to_string())) + .await + .expect("persist default wallet"); +} + +async fn set_stale_default_chain(app: &BeamApp) { + app.chain_store + .set(BeamChains { + chains: vec![ConfiguredChain { + aliases: Vec::new(), + chain_id: STALE_CHAIN_ID, + name: "Forgotten Chain".to_string(), + native_symbol: "FGT".to_string(), + }], + }) + .await + .expect("persist custom chains"); + + app.config_store + .update(|config| { + config.default_chain = STALE_CHAIN_KEY.to_string(); + config.rpc_configs.remove(STALE_CHAIN_KEY); + }) + .await + .expect("persist default chain"); +} + +async fn set_rpc_config(app: &BeamApp, chain_key: &str, rpc_url: &str) { + let rpc_url = rpc_url.to_string(); + app.config_store + .update(move |config| { + config.rpc_configs.insert( + chain_key.to_string(), + ChainRpcConfig { + default_rpc: rpc_url.clone(), + rpc_urls: vec![rpc_url.clone()], + }, + ); + }) + .await + .expect("persist rpc config"); +} + +async fn spawn_payment_prepare_rpc_server( + chain_id: u64, + native_balance: U256, +) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind payment prepare rpc listener"); + let address = listener.local_addr().expect("listener address"); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept rpc connection"); + let request = read_rpc_request(&mut stream).await; + let body = payment_prepare_rpc_response(&request, chain_id, native_balance); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); + } + }); + + (format!("http://{address}"), server) +} + +fn payment_prepare_rpc_response(request: &Value, chain_id: u64, native_balance: U256) -> String { + let result = match request["method"].as_str().expect("rpc method") { + "eth_chainId" => serde_json::to_value(U256::from(chain_id)).expect("chain id"), + "eth_estimateGas" => serde_json::to_value(U256::from(21_000u64)).expect("estimate gas"), + "eth_gasPrice" => serde_json::to_value(U256::from(1_000_000_000u64)).expect("gas price"), + "eth_getBalance" => serde_json::to_value(native_balance).expect("native balance"), + other => panic!("unexpected rpc method {other}"), + }; + + json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} diff --git a/pkg/beam-cli/src/tests/fetch_redirect.rs b/pkg/beam-cli/src/tests/fetch_redirect.rs new file mode 100644 index 0000000..d0a4113 --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_redirect.rs @@ -0,0 +1,139 @@ +use reqwest::{StatusCode, Url}; + +use super::fetch_test_servers::{ + spawn_header_recording_server, spawn_redirect_server, spawn_same_origin_redirect_server, +}; +use crate::{ + cli::FetchArgs, + commands::fetch::{build_initial_request_client_for_test, build_payment_retry_client_for_test}, +}; + +#[tokio::test] +async fn initial_redirect_stops_before_cross_origin_x_api_key_leak() { + let (destination_url, observed_headers, destination_server) = + spawn_header_recording_server("x-api-key").await; + let (origin_url, origin_server) = spawn_redirect_server(destination_url).await; + let request_url = Url::parse(&origin_url).expect("origin url"); + let client = build_initial_request_client_for_test(&fetch_args(), &request_url) + .expect("build initial fetch client"); + + let response = client + .get(origin_url) + .header("x-api-key", "secret") + .send() + .await + .expect("send initial request"); + + origin_server.abort(); + destination_server.abort(); + + assert_eq!(response.status(), StatusCode::FOUND); + assert!( + observed_headers + .lock() + .expect("observed headers") + .is_empty() + ); +} + +#[tokio::test] +async fn initial_redirect_allows_same_origin_x_api_key_redirects() { + let (origin_url, observed_headers, server) = + spawn_same_origin_redirect_server("x-api-key").await; + let request_url = Url::parse(&origin_url).expect("origin url"); + let client = build_initial_request_client_for_test(&fetch_args(), &request_url) + .expect("build initial fetch client"); + + let response = client + .get(origin_url) + .header("x-api-key", "secret") + .send() + .await + .expect("send initial request"); + + server.abort(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + observed_headers + .lock() + .expect("observed headers") + .as_slice(), + [Some("secret".to_string())] + ); +} + +#[tokio::test] +async fn paid_retry_redirect_stops_before_cross_origin_payment_signature_leak() { + let (destination_url, observed_headers, destination_server) = + spawn_header_recording_server("payment-signature").await; + let (origin_url, origin_server) = spawn_redirect_server(destination_url).await; + let original_url = Url::parse(&origin_url).expect("origin url"); + let client = build_payment_retry_client_for_test(&fetch_args(), &original_url) + .expect("build restricted retry client"); + + let response = client + .get(origin_url) + .header("payment-signature", "proof") + .send() + .await + .expect("send retry request"); + + origin_server.abort(); + destination_server.abort(); + + assert_eq!(response.status(), StatusCode::FOUND); + assert!( + observed_headers + .lock() + .expect("observed headers") + .is_empty() + ); +} + +#[tokio::test] +async fn paid_retry_redirect_allows_same_origin_payment_signature_redirects() { + let (origin_url, observed_headers, server) = + spawn_same_origin_redirect_server("payment-signature").await; + let original_url = Url::parse(&origin_url).expect("origin url"); + let client = build_payment_retry_client_for_test(&fetch_args(), &original_url) + .expect("build restricted retry client"); + + let response = client + .get(origin_url) + .header("payment-signature", "proof") + .send() + .await + .expect("send retry request"); + + server.abort(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + observed_headers + .lock() + .expect("observed headers") + .as_slice(), + [Some("proof".to_string())] + ); +} + +fn fetch_args() -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: true, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev: false, + } +} diff --git a/pkg/beam-cli/src/tests/fetch_request.rs b/pkg/beam-cli/src/tests/fetch_request.rs new file mode 100644 index 0000000..794e72a --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_request.rs @@ -0,0 +1,290 @@ +// lint-long-file-override allow-max-lines=300 +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use mockito::mock; +use reqwest::Url; +use serial_test::serial; + +use super::fixtures::test_app_with_output; +use crate::{ + cli::FetchArgs, + commands::fetch::{ + self, ensure_payment_challenge_transport_for_test, printable_request_header_value_for_test, + }, + error::Error, + output::OutputMode, + runtime::InvocationOverrides, +}; + +fn x402_v1_fixture() -> &'static str { + include_str!("fixtures/fetch_x402_v1.json") +} + +fn mpp_problem_fixture() -> &'static str { + include_str!("fixtures/fetch_mpp_problem.json") +} + +fn mpp_request_fixture() -> &'static str { + include_str!("fixtures/fetch_mpp_request.json") +} + +#[tokio::test] +#[serial] +async fn fetch_defaults_to_post_when_inline_data_is_present() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let _endpoint = mock("POST", "/submit") + .match_body("hello") + .with_status(200) + .with_body("ok") + .create(); + + fetch::run( + &app, + FetchArgs { + url: format!("{}/submit", mockito::server_url()), + method: None, + headers: Vec::new(), + data: Some("hello".to_string()), + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev: false, + }, + ) + .await + .expect("fetch inline body"); +} + +#[tokio::test] +#[serial] +async fn fetch_streams_request_body_from_data_file() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let data = tempfile::NamedTempFile::new().expect("create data file"); + std::fs::write(data.path(), "hello from file").expect("write data file"); + let _endpoint = mock("POST", "/submit") + .match_header("content-length", "15") + .match_body("hello from file") + .with_status(200) + .with_body("ok") + .create(); + + fetch::run( + &app, + FetchArgs { + url: format!("{}/submit", mockito::server_url()), + method: None, + headers: Vec::new(), + data: None, + data_file: Some(data.path().to_string_lossy().to_string()), + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev: false, + }, + ) + .await + .expect("fetch file body"); +} + +#[tokio::test] +#[serial] +async fn fetch_rejects_http_payment_challenge_without_dev_flag() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let challenge_url = format!("{}/paid", mockito::server_url()); + let _endpoint = mock("GET", "/paid") + .with_status(402) + .with_header("content-type", "application/json") + .with_body(x402_v1_fixture()) + .create(); + + let err = fetch::run( + &app, + FetchArgs { + url: challenge_url.clone(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: true, + dev: false, + }, + ) + .await + .expect_err("reject insecure payment challenge"); + + assert!(matches!( + err, + Error::FetchPaymentRequiresHttps { url } if url == challenge_url + )); +} + +#[tokio::test] +#[serial] +async fn fetch_allows_loopback_http_payment_challenge_with_dev_flag() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let _endpoint = mock("GET", "/paid") + .with_status(402) + .with_header("content-type", "application/json") + .with_body(x402_v1_fixture()) + .create(); + + let err = fetch::run( + &app, + FetchArgs { + url: format!("{}/paid", mockito::server_url()), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: true, + dev: true, + }, + ) + .await + .expect_err("no-pay still exits after parsing challenge"); + + assert!(matches!(err, Error::FetchPaymentRequired)); +} + +#[tokio::test] +#[serial] +async fn fetch_rejects_mpp_retry_when_request_already_has_authorization_header() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let request = URL_SAFE_NO_PAD.encode(mpp_request_fixture().as_bytes()); + let authenticate = format!( + "Payment id=\"challenge_123\", realm=\"api.example.com\", method=\"tempo.charge\", intent=\"charge\", request=\"{request}\"" + ); + let _endpoint = mock("GET", "/paid") + .match_header("authorization", "Bearer user-token") + .with_status(402) + .with_header("content-type", "application/json") + .with_header("www-authenticate", &authenticate) + .with_body(mpp_problem_fixture()) + .create(); + + let err = fetch::run( + &app, + FetchArgs { + url: format!("{}/paid", mockito::server_url()), + method: Some("GET".to_string()), + headers: vec!["Authorization: Bearer user-token".to_string()], + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev: true, + }, + ) + .await + .expect_err("reject conflicting authorization retry"); + + assert!(matches!(err, Error::FetchPaymentAuthorizationConflict)); +} + +#[test] +fn payment_challenge_transport_rejects_remote_http_even_with_dev_flag() { + let err = ensure_payment_challenge_transport_for_test( + &fetch_args(true), + &Url::parse("http://api.example.com/paid").expect("remote challenge url"), + ) + .expect_err("reject remote http payment challenge"); + + assert!(matches!( + err, + Error::FetchPaymentRequiresHttps { url } if url == "http://api.example.com/paid" + )); +} + +#[test] +fn payment_challenge_transport_allows_loopback_http_with_dev_flag() { + ensure_payment_challenge_transport_for_test( + &fetch_args(true), + &Url::parse("http://127.0.0.1:8080/paid").expect("loopback challenge url"), + ) + .expect("allow local http payment challenge"); +} + +#[test] +fn verbose_request_logging_redacts_sensitive_headers() { + for header in [ + "Authorization", + "Proxy-Authorization", + "Cookie", + "payment-signature", + "x-payment", + ] { + assert_eq!( + printable_request_header_value_for_test(header, "secret"), + "", + "expected {header} to be redacted", + ); + } +} + +#[test] +fn verbose_request_logging_keeps_non_sensitive_headers_visible() { + assert_eq!( + printable_request_header_value_for_test("Content-Type", "application/json"), + "application/json", + ); +} + +fn fetch_args(dev: bool) -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev, + } +} diff --git a/pkg/beam-cli/src/tests/fetch_retry_effective_request.rs b/pkg/beam-cli/src/tests/fetch_retry_effective_request.rs new file mode 100644 index 0000000..ebc8f52 --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_retry_effective_request.rs @@ -0,0 +1,172 @@ +use reqwest::{ + Method, StatusCode, Url, + header::{HeaderMap, HeaderName, HeaderValue}, +}; + +use super::fetch_test_servers::{ + request_body, request_header, request_method, + spawn_same_origin_redirect_challenge_server_with_status, +}; +use crate::{ + cli::FetchArgs, + commands::fetch::{ + protocol::RetryHeader, send_request_for_test, send_retry_request_with_spec_for_test, + }, +}; + +#[tokio::test] +async fn same_origin_302_payment_challenge_retries_effective_get_request() { + let (request_url, challenged_requests, server) = + spawn_same_origin_redirect_challenge_server_with_status( + StatusCode::FOUND.as_u16(), + "Found", + ) + .await; + let request_url = Url::parse(&request_url).expect("request url"); + + let sent = send_request_for_test( + &fetch_args(), + &request_url, + Method::POST, + request_headers(), + Some(b"hello".to_vec()), + ) + .await + .expect("send initial request"); + + assert_eq!(sent.status, StatusCode::PAYMENT_REQUIRED); + assert_eq!(sent.effective_spec.method, Method::GET); + assert_eq!( + sent.effective_spec.url, + request_url.join("/paid").expect("challenged url") + ); + assert_eq!(sent.effective_spec.body, None); + assert!(!sent.effective_spec.headers.contains_key("content-type")); + assert!(!sent.effective_spec.headers.contains_key("content-length")); + + let response = send_retry_request_with_spec_for_test( + &fetch_args(), + &sent.effective_spec.url, + &sent.effective_spec.url, + sent.effective_spec.method.clone(), + sent.effective_spec.headers.clone(), + sent.effective_spec.body.clone(), + RetryHeader { + name: HeaderName::from_static("payment-signature"), + value: HeaderValue::from_static("proof"), + }, + ) + .await + .expect("send retry request"); + + server.abort(); + + assert_eq!(response.status(), StatusCode::OK); + + let challenged_requests = challenged_requests.lock().expect("challenged requests"); + assert_eq!(challenged_requests.len(), 2); + + let initial_request = challenged_requests + .first() + .expect("initial challenged request"); + assert_eq!(request_method(initial_request), "GET"); + assert_eq!( + request_header(initial_request, "x-api-key"), + Some("secret".to_string()) + ); + assert_eq!(request_header(initial_request, "content-type"), None); + assert!(request_body(initial_request).is_empty()); + + let retry_request = challenged_requests + .get(1) + .expect("retry challenged request"); + assert_eq!(request_method(retry_request), "GET"); + assert_eq!( + request_header(retry_request, "x-api-key"), + Some("secret".to_string()) + ); + assert_eq!( + request_header(retry_request, "payment-signature"), + Some("proof".to_string()) + ); + assert_eq!(request_header(retry_request, "content-type"), None); + assert!(request_body(retry_request).is_empty()); +} + +#[tokio::test] +async fn same_origin_303_payment_challenge_uses_effective_get_request() { + let (request_url, challenged_requests, server) = + spawn_same_origin_redirect_challenge_server_with_status( + StatusCode::SEE_OTHER.as_u16(), + "See Other", + ) + .await; + let request_url = Url::parse(&request_url).expect("request url"); + + let sent = send_request_for_test( + &fetch_args(), + &request_url, + Method::PUT, + request_headers(), + Some(b"hello".to_vec()), + ) + .await + .expect("send initial request"); + + server.abort(); + + assert_eq!(sent.status, StatusCode::PAYMENT_REQUIRED); + assert_eq!(sent.effective_spec.method, Method::GET); + assert_eq!( + sent.effective_spec.url, + request_url.join("/paid").expect("challenged url") + ); + assert_eq!(sent.effective_spec.body, None); + assert!(!sent.effective_spec.headers.contains_key("content-type")); + assert!(!sent.effective_spec.headers.contains_key("content-length")); + + let challenged_requests = challenged_requests.lock().expect("challenged requests"); + assert_eq!(challenged_requests.len(), 1); + + let challenged_request = challenged_requests.first().expect("challenged request"); + assert_eq!(request_method(challenged_request), "GET"); + assert_eq!( + request_header(challenged_request, "x-api-key"), + Some("secret".to_string()) + ); + assert_eq!(request_header(challenged_request, "content-type"), None); + assert!(request_body(challenged_request).is_empty()); +} + +fn fetch_args() -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: true, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev: false, + } +} + +fn request_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-api-key"), + HeaderValue::from_static("secret"), + ); + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("text/plain"), + ); + headers +} diff --git a/pkg/beam-cli/src/tests/fetch_retry_origin.rs b/pkg/beam-cli/src/tests/fetch_retry_origin.rs new file mode 100644 index 0000000..b4a5f9f --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_retry_origin.rs @@ -0,0 +1,294 @@ +// lint-long-file-override allow-max-lines=300 +use reqwest::{ + Method, StatusCode, Url, + header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}, +}; + +use super::fetch_test_servers::{ + request_body, request_header, request_method, spawn_header_recording_server, + spawn_recording_redirect_server, spawn_recording_redirect_server_with_status, + spawn_request_recording_server, +}; +use crate::{ + cli::FetchArgs, + commands::fetch::{ + protocol::RetryHeader, send_retry_request_for_test, send_retry_request_with_spec_for_test, + }, + error::Error, +}; + +#[tokio::test] +async fn paid_retry_request_uses_challenged_url_for_payment_signature() { + let (destination_url, destination_headers, destination_server) = + spawn_header_recording_server("payment-signature").await; + let (origin_url, origin_paths, origin_server) = + spawn_recording_redirect_server(destination_url.clone()).await; + let request_url = Url::parse(&format!("{origin_url}/start")).expect("request url"); + let challenged_url = Url::parse(&format!("{destination_url}/paid")).expect("challenged url"); + + let response = send_retry_request_for_test( + &fetch_args(), + &request_url, + &challenged_url, + RetryHeader { + name: HeaderName::from_static("payment-signature"), + value: HeaderValue::from_static("proof"), + }, + ) + .await + .expect("send retry request"); + + origin_server.abort(); + destination_server.abort(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(origin_paths.lock().expect("origin paths").is_empty()); + assert_eq!( + destination_headers + .lock() + .expect("destination headers") + .as_slice(), + [Some("proof".to_string())] + ); +} + +#[tokio::test] +async fn paid_retry_request_uses_challenged_url_for_authorization() { + let (destination_url, destination_headers, destination_server) = + spawn_header_recording_server("authorization").await; + let (origin_url, origin_paths, origin_server) = + spawn_recording_redirect_server(destination_url.clone()).await; + let request_url = Url::parse(&format!("{origin_url}/start")).expect("request url"); + let challenged_url = Url::parse(&format!("{destination_url}/paid")).expect("challenged url"); + + let response = send_retry_request_for_test( + &fetch_args(), + &request_url, + &challenged_url, + RetryHeader { + name: HeaderName::from_static("authorization"), + value: HeaderValue::from_static("Payment proof"), + }, + ) + .await + .expect("send retry request"); + + origin_server.abort(); + destination_server.abort(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(origin_paths.lock().expect("origin paths").is_empty()); + assert_eq!( + destination_headers + .lock() + .expect("destination headers") + .as_slice(), + [Some("Payment proof".to_string())] + ); +} + +#[tokio::test] +async fn cross_origin_payment_signature_retry_drops_original_request_metadata() { + let (destination_url, destination_requests, destination_server) = + spawn_request_recording_server().await; + let (origin_url, origin_paths, origin_server) = + spawn_recording_redirect_server(destination_url.clone()).await; + let request_url = Url::parse(&format!("{origin_url}/start")).expect("request url"); + let challenged_url = Url::parse(&format!("{destination_url}/paid")).expect("challenged url"); + + let response = send_retry_request_with_spec_for_test( + &fetch_args(), + &request_url, + &challenged_url, + Method::POST, + request_headers(), + Some(b"hello".to_vec()), + RetryHeader { + name: HeaderName::from_static("payment-signature"), + value: HeaderValue::from_static("proof"), + }, + ) + .await + .expect("send retry request"); + + origin_server.abort(); + destination_server.abort(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(origin_paths.lock().expect("origin paths").is_empty()); + + let requests = destination_requests.lock().expect("destination requests"); + let request = requests.first().expect("recorded request"); + assert_eq!(request_method(request), "GET"); + assert_eq!( + request_header(request, "payment-signature"), + Some("proof".to_string()) + ); + assert_eq!(request_header(request, "authorization"), None); + assert_eq!(request_header(request, "cookie"), None); + assert_eq!(request_header(request, "x-api-key"), None); + assert_eq!(request_header(request, "content-type"), None); + assert!(request_body(request).is_empty()); +} + +#[tokio::test] +async fn cross_origin_authorization_retry_drops_original_request_metadata() { + let (destination_url, destination_requests, destination_server) = + spawn_request_recording_server().await; + let (origin_url, origin_paths, origin_server) = + spawn_recording_redirect_server(destination_url.clone()).await; + let request_url = Url::parse(&format!("{origin_url}/start")).expect("request url"); + let challenged_url = Url::parse(&format!("{destination_url}/paid")).expect("challenged url"); + + let response = send_retry_request_with_spec_for_test( + &fetch_args(), + &request_url, + &challenged_url, + Method::POST, + request_headers(), + Some(b"hello".to_vec()), + RetryHeader { + name: HeaderName::from_static("authorization"), + value: HeaderValue::from_static("Payment proof"), + }, + ) + .await + .expect("send retry request"); + + origin_server.abort(); + destination_server.abort(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(origin_paths.lock().expect("origin paths").is_empty()); + + let requests = destination_requests.lock().expect("destination requests"); + let request = requests.first().expect("recorded request"); + assert_eq!(request_method(request), "GET"); + assert_eq!( + request_header(request, "authorization"), + Some("Payment proof".to_string()) + ); + assert_eq!(request_header(request, "payment-signature"), None); + assert_eq!(request_header(request, "cookie"), None); + assert_eq!(request_header(request, "x-api-key"), None); + assert_eq!(request_header(request, "content-type"), None); + assert!(request_body(request).is_empty()); +} + +#[tokio::test] +async fn same_origin_authorization_retry_rejects_existing_authorization_header() { + let (origin_url, origin_requests, origin_server) = spawn_request_recording_server().await; + let request_url = Url::parse(&format!("{origin_url}/start")).expect("request url"); + let challenged_url = Url::parse(&format!("{origin_url}/paid")).expect("challenged url"); + + let err = send_retry_request_with_spec_for_test( + &fetch_args(), + &request_url, + &challenged_url, + Method::POST, + request_headers(), + Some(b"hello".to_vec()), + RetryHeader { + name: HeaderName::from_static("authorization"), + value: HeaderValue::from_static("Payment proof"), + }, + ) + .await + .expect_err("reject conflicting authorization retry"); + + origin_server.abort(); + + assert!(matches!(err, Error::FetchPaymentAuthorizationConflict)); + assert!(origin_requests.lock().expect("origin requests").is_empty()); +} + +#[tokio::test] +async fn same_origin_authorization_retry_stops_before_cross_origin_redirect() { + let (destination_url, destination_requests, destination_server) = + spawn_request_recording_server().await; + let (origin_url, origin_paths, origin_server) = spawn_recording_redirect_server_with_status( + destination_url.clone(), + StatusCode::TEMPORARY_REDIRECT.as_u16(), + "Temporary Redirect", + ) + .await; + let request_url = Url::parse(&format!("{origin_url}/start")).expect("request url"); + let challenged_url = Url::parse(&format!("{origin_url}/paid")).expect("challenged url"); + + let response = send_retry_request_with_spec_for_test( + &fetch_args(), + &request_url, + &challenged_url, + Method::POST, + request_headers_without_authorization(), + Some(b"hello".to_vec()), + RetryHeader { + name: HeaderName::from_static("authorization"), + value: HeaderValue::from_static("Payment proof"), + }, + ) + .await + .expect("send retry request"); + + origin_server.abort(); + destination_server.abort(); + + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + origin_paths.lock().expect("origin paths").as_slice(), + ["/paid".to_string()] + ); + assert!( + destination_requests + .lock() + .expect("destination requests") + .is_empty() + ); +} + +fn fetch_args() -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: true, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: Vec::new(), + no_pay: false, + dev: false, + } +} + +fn request_headers_without_authorization() -> HeaderMap { + let mut headers = request_headers(); + headers.remove(AUTHORIZATION); + headers +} + +fn request_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("authorization"), + HeaderValue::from_static("Bearer user-token"), + ); + headers.insert( + HeaderName::from_static("cookie"), + HeaderValue::from_static("session=abc"), + ); + headers.insert( + HeaderName::from_static("x-api-key"), + HeaderValue::from_static("secret"), + ); + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("text/plain"), + ); + headers +} diff --git a/pkg/beam-cli/src/tests/fetch_test_servers.rs b/pkg/beam-cli/src/tests/fetch_test_servers.rs new file mode 100644 index 0000000..b693dba --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_test_servers.rs @@ -0,0 +1,302 @@ +// lint-long-file-override allow-max-lines=400 +use std::sync::{Arc, Mutex}; + +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +pub(crate) type RecordedHeaders = Arc>>>; +pub(crate) type RecordedPaths = Arc>>; +pub(crate) type RecordedRequests = Arc>>; +pub(crate) type ServerHandle = tokio::task::JoinHandle<()>; + +pub(crate) async fn spawn_redirect_server(location: String) -> (String, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind redirect listener"); + let address = listener.local_addr().expect("redirect listener address"); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept redirect request"); + let _request = read_http_request(&mut stream).await; + let response = format!( + "HTTP/1.1 302 Found\r\nlocation: {location}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write redirect response"); + } + }); + + (format!("http://{address}"), server) +} + +pub(crate) async fn spawn_recording_redirect_server( + location: String, +) -> (String, RecordedPaths, ServerHandle) { + spawn_recording_redirect_server_with_status(location, 302, "Found").await +} + +pub(crate) async fn spawn_recording_redirect_server_with_status( + location: String, + status_code: u16, + reason: &'static str, +) -> (String, RecordedPaths, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind redirect listener"); + let address = listener.local_addr().expect("redirect listener address"); + let observed_paths = Arc::new(Mutex::new(Vec::new())); + let server_paths = Arc::clone(&observed_paths); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept redirect request"); + let request = read_http_request(&mut stream).await; + server_paths + .lock() + .expect("record redirect path") + .push(request_path(&request).to_string()); + let response = format!( + "HTTP/1.1 {status_code} {reason}\r\nlocation: {location}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write redirect response"); + } + }); + + (format!("http://{address}"), observed_paths, server) +} + +pub(crate) async fn spawn_header_recording_server( + header_name: &'static str, +) -> (String, RecordedHeaders, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind recording listener"); + let address = listener.local_addr().expect("recording listener address"); + let observed_headers = Arc::new(Mutex::new(Vec::new())); + let server_headers = Arc::clone(&observed_headers); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept recorded request"); + let request = read_http_request(&mut stream).await; + server_headers + .lock() + .expect("record observed header") + .push(request_header(&request, header_name)); + write_ok_response(&mut stream).await; + } + }); + + (format!("http://{address}"), observed_headers, server) +} + +pub(crate) async fn spawn_request_recording_server() -> (String, RecordedRequests, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind recording listener"); + let address = listener.local_addr().expect("recording listener address"); + let observed_requests = Arc::new(Mutex::new(Vec::new())); + let server_requests = Arc::clone(&observed_requests); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept recorded request"); + let request = read_http_request(&mut stream).await; + server_requests + .lock() + .expect("record observed request") + .push(request); + write_ok_response(&mut stream).await; + } + }); + + (format!("http://{address}"), observed_requests, server) +} + +pub(crate) async fn spawn_same_origin_redirect_server( + header_name: &'static str, +) -> (String, RecordedHeaders, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind same-origin listener"); + let address = listener.local_addr().expect("same-origin listener address"); + let observed_headers = Arc::new(Mutex::new(Vec::new())); + let server_headers = Arc::clone(&observed_headers); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept same-origin request"); + let request = read_http_request(&mut stream).await; + match request_path(&request) { + "/paid" => { + let response = "HTTP/1.1 302 Found\r\nlocation: /settled\r\ncontent-length: 0\r\nconnection: close\r\n\r\n"; + stream + .write_all(response.as_bytes()) + .await + .expect("write same-origin redirect response"); + } + "/settled" => { + server_headers + .lock() + .expect("record same-origin header") + .push(request_header(&request, header_name)); + write_ok_response(&mut stream).await; + } + path => panic!("unexpected path {path}"), + } + } + }); + + (format!("http://{address}/paid"), observed_headers, server) +} + +pub(crate) async fn spawn_same_origin_redirect_challenge_server_with_status( + status_code: u16, + reason: &'static str, +) -> (String, RecordedRequests, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind same-origin challenge listener"); + let address = listener + .local_addr() + .expect("same-origin challenge listener address"); + let observed_requests = Arc::new(Mutex::new(Vec::new())); + let server_requests = Arc::clone(&observed_requests); + let challenge_count = Arc::new(Mutex::new(0usize)); + let server_challenge_count = Arc::clone(&challenge_count); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener + .accept() + .await + .expect("accept same-origin challenge request"); + let request = read_http_request(&mut stream).await; + + match request_path(&request) { + "/start" => { + let response = format!( + "HTTP/1.1 {status_code} {reason}\r\nlocation: /paid\r\ncontent-length: 0\r\nconnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write same-origin challenge redirect response"); + } + "/paid" => { + server_requests + .lock() + .expect("record same-origin challenged request") + .push(request); + + let should_challenge = { + let mut challenge_count = server_challenge_count + .lock() + .expect("lock same-origin challenge count"); + let should_challenge = *challenge_count == 0; + *challenge_count += 1; + should_challenge + }; + + if should_challenge { + stream + .write_all( + b"HTTP/1.1 402 Payment Required\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ) + .await + .expect("write same-origin challenge response"); + } else { + write_ok_response(&mut stream).await; + } + } + path => panic!("unexpected path {path}"), + } + } + }); + + (format!("http://{address}/start"), observed_requests, server) +} + +pub(crate) fn request_body(request: &str) -> &str { + request + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .unwrap_or("") +} + +pub(crate) fn request_header(request: &str, name: &str) -> Option { + request.lines().find_map(|line| { + let (header_name, value) = line.split_once(':')?; + header_name + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) +} + +pub(crate) fn request_method(request: &str) -> &str { + request + .lines() + .next() + .and_then(|line| line.split_whitespace().next()) + .expect("request method") +} + +pub(crate) fn request_path(request: &str) -> &str { + request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .expect("request path") +} + +async fn read_http_request(stream: &mut TcpStream) -> String { + let mut buffer = Vec::new(); + + loop { + let mut chunk = [0u8; 1024]; + let read = stream.read(&mut chunk).await.expect("read http request"); + assert!(read > 0, "http request closed before headers"); + buffer.extend_from_slice(&chunk[..read]); + + if let Some(header_end) = header_end(&buffer) { + let content_length = content_length(&buffer[..header_end]); + if buffer.len() >= header_end + 4 + content_length { + break; + } + } + } + + String::from_utf8(buffer).expect("utf8 request") +} + +fn header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn content_length(headers: &[u8]) -> usize { + String::from_utf8_lossy(headers) + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("content-length")) + }) + .unwrap_or(0) +} + +async fn write_ok_response(stream: &mut TcpStream) { + stream + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok", + ) + .await + .expect("write ok response"); +} diff --git a/pkg/beam-cli/src/tests/fetch_x402.rs b/pkg/beam-cli/src/tests/fetch_x402.rs new file mode 100644 index 0000000..a9bb662 --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_x402.rs @@ -0,0 +1,294 @@ +// lint-long-file-override allow-max-lines=300 +use contracts::U256; +use serde_json::Value; +use tokio::{io::AsyncWriteExt, net::TcpListener}; + +use super::fixtures::{read_rpc_request, test_app}; +use crate::{ + cli::FetchArgs, + commands::fetch::{ + payment::prepare_x402_payment, + protocol::{AmountValue, X402Challenge, X402Offer}, + }, + config::ChainRpcConfig, + error::Error, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides}, +}; + +const TEST_WALLET_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; +const RECIPIENT_ADDRESS: &str = "0x3333333333333333333333333333333333333333"; + +#[tokio::test] +async fn prepare_x402_payment_selects_offer_allowed_by_chain_allowlist() { + let (ethereum_rpc, ethereum_server) = spawn_x402_offer_rpc_server(1, U256::exp10(18)).await; + let (base_rpc, base_server) = spawn_x402_offer_rpc_server(8453, U256::exp10(18)).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_rpc_config(&app, "ethereum", ðereum_rpc).await; + set_rpc_config(&app, "base", &base_rpc).await; + + let payment = prepare_x402_payment( + &app, + &fetch_args(None, &["base"]), + &x402_challenge(vec![ + native_offer("eip155:1", "100000000000000000"), + native_offer("eip155:8453", "100000000000000000"), + ]), + ) + .await + .expect("prepare x402 payment"); + + ethereum_server.abort(); + base_server.abort(); + + assert_eq!(payment.chain.key, "base"); +} + +#[tokio::test] +async fn prepare_x402_payment_skips_offers_without_sufficient_balance() { + let (ethereum_rpc, ethereum_server) = + spawn_x402_offer_rpc_server(1, U256::from(100_000_000_000_000_000u64)).await; + let (base_rpc, base_server) = + spawn_x402_offer_rpc_server(8453, U256::from(2_000_000_000_000_000_000u64)).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_rpc_config(&app, "ethereum", ðereum_rpc).await; + set_rpc_config(&app, "base", &base_rpc).await; + + let payment = prepare_x402_payment( + &app, + &fetch_args(None, &[]), + &x402_challenge(vec![ + native_offer("eip155:1", "500000000000000000"), + native_offer("eip155:8453", "100000000000000000"), + ]), + ) + .await + .expect("prepare x402 payment"); + + ethereum_server.abort(); + base_server.abort(); + + assert_eq!(payment.chain.key, "base"); +} + +#[tokio::test] +async fn prepare_x402_payment_skips_offers_above_max_fee() { + let (base_rpc, base_server) = spawn_x402_offer_rpc_server(8453, U256::exp10(18)).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_rpc_config(&app, "base", &base_rpc).await; + + let payment = prepare_x402_payment( + &app, + &fetch_args(Some("0.11"), &[]), + &x402_challenge(vec![ + native_offer("eip155:8453", "150000000000000000"), + native_offer("eip155:8453", "100000000000000000"), + ]), + ) + .await + .expect("prepare x402 payment"); + + base_server.abort(); + + assert_eq!(payment.chain.key, "base"); + assert_eq!(payment.amount, U256::from(100_000_000_000_000_000u64)); +} + +#[tokio::test] +async fn prepare_x402_payment_returns_max_fee_error_when_every_offer_is_too_expensive() { + let (base_rpc, base_server) = spawn_x402_offer_rpc_server(8453, U256::exp10(18)).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_rpc_config(&app, "base", &base_rpc).await; + + let err = prepare_x402_payment( + &app, + &fetch_args(Some("0.11"), &[]), + &x402_challenge(vec![ + native_offer("eip155:8453", "150000000000000000"), + native_offer("eip155:8453", "120000000000000000"), + ]), + ) + .await + .expect_err("reject over-cap offers"); + + base_server.abort(); + + assert!(matches!(err, Error::FetchPaymentExceedsMaxFee)); +} + +#[tokio::test] +async fn prepare_x402_payment_returns_chain_not_allowed_when_allowlist_filters_every_offer() { + let (base_rpc, base_server) = spawn_x402_offer_rpc_server(8453, U256::exp10(18)).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_rpc_config(&app, "base", &base_rpc).await; + + let err = prepare_x402_payment( + &app, + &fetch_args(None, &["ethereum"]), + &x402_challenge(vec![native_offer("eip155:8453", "100000000000000000")]), + ) + .await + .expect_err("reject disallowed chains"); + + base_server.abort(); + + match err { + Error::FetchPaymentChainNotAllowed { chain } => { + assert_eq!(chain, "Base (8453)"); + } + other => panic!("unexpected error: {other:?}"), + } +} + +#[tokio::test] +async fn prepare_x402_payment_returns_chain_mismatch_when_selector_filters_every_offer() { + let (_temp_dir, app) = test_app(InvocationOverrides { + chain: Some("ethereum".to_string()), + ..InvocationOverrides::default() + }) + .await; + + let err = prepare_x402_payment( + &app, + &fetch_args(None, &[]), + &x402_challenge(vec![ + native_offer("eip155:8453", "100000000000000000"), + native_offer("eip155:137", "100000000000000000"), + ]), + ) + .await + .expect_err("reject mismatched explicit chain"); + + assert!(matches!( + err, + Error::FetchPaymentChainMismatch { challenge, selected } + if challenge == "Base (8453), Polygon (137)" + && selected == "Ethereum (1)" + )); +} + +fn fetch_args(max_fee: Option<&str>, allowed_chains: &[&str]) -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: max_fee.map(ToString::to_string), + allowed_chains: allowed_chains.iter().map(ToString::to_string).collect(), + no_pay: false, + dev: false, + } +} + +fn native_offer(network: &str, amount: &str) -> X402Offer { + X402Offer { + amount: AmountValue::Atomic(amount.to_string()), + asset: "native".to_string(), + network: network.to_string(), + pay_to: RECIPIENT_ADDRESS.to_string(), + raw: Value::Null, + scheme: "exact".to_string(), + } +} + +fn x402_challenge(offers: Vec) -> X402Challenge { + X402Challenge { + offers, + resource: None, + version: 2, + } +} + +async fn seed_default_wallet(app: &BeamApp) { + app.keystore_store + .set(KeyStore { + wallets: vec![StoredWallet { + address: TEST_WALLET_ADDRESS.to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "alice".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }], + }) + .await + .expect("persist keystore"); + + app.config_store + .update(|config| config.default_wallet = Some("alice".to_string())) + .await + .expect("persist default wallet"); +} + +async fn set_rpc_config(app: &BeamApp, chain_key: &str, rpc_url: &str) { + let rpc_url = rpc_url.to_string(); + app.config_store + .update(move |config| { + config.rpc_configs.insert( + chain_key.to_string(), + ChainRpcConfig { + default_rpc: rpc_url.clone(), + rpc_urls: vec![rpc_url.clone()], + }, + ); + }) + .await + .expect("persist rpc config"); +} + +async fn spawn_x402_offer_rpc_server( + chain_id: u64, + native_balance: U256, +) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind x402 offer rpc listener"); + let address = listener.local_addr().expect("listener address"); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept rpc connection"); + let request = read_rpc_request(&mut stream).await; + let body = x402_offer_rpc_response(&request, chain_id, native_balance); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); + } + }); + + (format!("http://{address}"), server) +} + +fn x402_offer_rpc_response(request: &Value, chain_id: u64, native_balance: U256) -> String { + let result = match request["method"].as_str().expect("rpc method") { + "eth_chainId" => serde_json::to_value(U256::from(chain_id)).expect("chain id"), + "eth_estimateGas" => serde_json::to_value(U256::from(21_000u64)).expect("estimate gas"), + "eth_gasPrice" => serde_json::to_value(U256::from(1_000_000_000u64)).expect("gas price"), + "eth_getBalance" => serde_json::to_value(native_balance).expect("native balance"), + other => panic!("unexpected rpc method {other}"), + }; + + serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} diff --git a/pkg/beam-cli/src/tests/fetch_x402_chain_aliases.rs b/pkg/beam-cli/src/tests/fetch_x402_chain_aliases.rs new file mode 100644 index 0000000..56f0f1a --- /dev/null +++ b/pkg/beam-cli/src/tests/fetch_x402_chain_aliases.rs @@ -0,0 +1,216 @@ +// lint-long-file-override allow-max-lines=220 +use contracts::U256; +use serde_json::Value; +use tokio::{io::AsyncWriteExt, net::TcpListener}; + +use super::fixtures::{read_rpc_request, test_app}; +use crate::{ + cli::FetchArgs, + commands::fetch::{ + payment::prepare_x402_payment, + protocol::{AmountValue, X402Challenge, X402Offer}, + }, + config::ChainRpcConfig, + keystore::{KeyStore, StoredKdf, StoredWallet}, + runtime::{BeamApp, InvocationOverrides}, +}; + +const TEST_WALLET_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; +const RECIPIENT_ADDRESS: &str = "0x3333333333333333333333333333333333333333"; + +#[tokio::test] +async fn prepare_x402_payment_accepts_alias_offer_for_normalized_chain_selector() { + let (payy_dev_rpc, payy_dev_server) = spawn_x402_offer_rpc_server(7297).await; + let (_temp_dir, app) = test_app(InvocationOverrides { + chain: Some("payy_dev".to_string()), + ..InvocationOverrides::default() + }) + .await; + seed_default_wallet(&app).await; + set_rpc_config(&app, "payy-dev", &payy_dev_rpc).await; + + let payment = prepare_x402_payment( + &app, + &fetch_args(&[]), + &x402_challenge(vec![native_offer("payydev")]), + ) + .await + .expect("prepare x402 payment for selector alias"); + + payy_dev_server.abort(); + + assert_eq!(payment.chain.key, "payy-dev"); + assert_eq!(payment.network, "payydev"); +} + +#[tokio::test] +async fn prepare_x402_payment_accepts_alias_offer_for_chain_allowlist() { + let (bnb_rpc, bnb_server) = spawn_x402_offer_rpc_server(56).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_rpc_config(&app, "bnb", &bnb_rpc).await; + + let payment = prepare_x402_payment( + &app, + &fetch_args(&["bnb"]), + &x402_challenge(vec![native_offer("bsc")]), + ) + .await + .expect("prepare x402 payment for allowlist alias"); + + bnb_server.abort(); + + assert_eq!(payment.chain.key, "bnb"); + assert_eq!(payment.network, "bsc"); +} + +#[tokio::test] +async fn prepare_x402_payment_prefers_default_chain_when_offer_uses_alias() { + let (base_rpc, base_server) = spawn_x402_offer_rpc_server(8453).await; + let (arbitrum_rpc, arbitrum_server) = spawn_x402_offer_rpc_server(42161).await; + let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; + seed_default_wallet(&app).await; + set_default_chain(&app, "arbitrum").await; + set_rpc_config(&app, "base", &base_rpc).await; + set_rpc_config(&app, "arbitrum", &arbitrum_rpc).await; + + let payment = prepare_x402_payment( + &app, + &fetch_args(&[]), + &x402_challenge(vec![native_offer("eip155:8453"), native_offer("arb")]), + ) + .await + .expect("prepare x402 payment for preferred alias"); + + base_server.abort(); + arbitrum_server.abort(); + + assert_eq!(payment.chain.key, "arbitrum"); + assert_eq!(payment.network, "arb"); +} + +fn fetch_args(allowed_chains: &[&str]) -> FetchArgs { + FetchArgs { + url: "https://api.example.com/paid".to_string(), + method: Some("GET".to_string()), + headers: Vec::new(), + data: None, + data_file: None, + output_path: None, + verbose: false, + follow_redirects: false, + max_redirects: 10, + connect_timeout: None, + timeout: None, + max_fee: None, + allowed_chains: allowed_chains.iter().map(ToString::to_string).collect(), + no_pay: false, + dev: false, + } +} + +fn native_offer(network: &str) -> X402Offer { + X402Offer { + amount: AmountValue::Atomic("100000000000000000".to_string()), + asset: "native".to_string(), + network: network.to_string(), + pay_to: RECIPIENT_ADDRESS.to_string(), + raw: Value::Null, + scheme: "exact".to_string(), + } +} + +fn x402_challenge(offers: Vec) -> X402Challenge { + X402Challenge { + offers, + resource: None, + version: 2, + } +} + +async fn seed_default_wallet(app: &BeamApp) { + app.keystore_store + .set(KeyStore { + wallets: vec![StoredWallet { + address: TEST_WALLET_ADDRESS.to_string(), + encrypted_key: "encrypted-key".to_string(), + name: "alice".to_string(), + salt: "salt".to_string(), + kdf: StoredKdf::default(), + }], + }) + .await + .expect("persist keystore"); + + app.config_store + .update(|config| config.default_wallet = Some("alice".to_string())) + .await + .expect("persist default wallet"); +} + +async fn set_default_chain(app: &BeamApp, default_chain: &str) { + let default_chain = default_chain.to_string(); + app.config_store + .update(move |config| config.default_chain = default_chain.clone()) + .await + .expect("persist default chain"); +} + +async fn set_rpc_config(app: &BeamApp, chain_key: &str, rpc_url: &str) { + let rpc_url = rpc_url.to_string(); + app.config_store + .update(move |config| { + config.rpc_configs.insert( + chain_key.to_string(), + ChainRpcConfig { + default_rpc: rpc_url.clone(), + rpc_urls: vec![rpc_url.clone()], + }, + ); + }) + .await + .expect("persist rpc config"); +} + +async fn spawn_x402_offer_rpc_server(chain_id: u64) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind x402 offer rpc listener"); + let address = listener.local_addr().expect("listener address"); + + let server = tokio::spawn(async move { + loop { + let (mut stream, _peer) = listener.accept().await.expect("accept rpc connection"); + let request = read_rpc_request(&mut stream).await; + let body = x402_offer_rpc_response(&request, chain_id); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write rpc response"); + } + }); + + (format!("http://{address}"), server) +} + +fn x402_offer_rpc_response(request: &Value, chain_id: u64) -> String { + let result = match request["method"].as_str().expect("rpc method") { + "eth_chainId" => serde_json::to_value(U256::from(chain_id)).expect("chain id"), + "eth_estimateGas" => serde_json::to_value(U256::from(21_000u64)).expect("estimate gas"), + "eth_gasPrice" => serde_json::to_value(U256::from(1_000_000_000u64)).expect("gas price"), + "eth_getBalance" => serde_json::to_value(U256::exp10(18)).expect("native balance"), + other => panic!("unexpected rpc method {other}"), + }; + + serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }) + .to_string() +} diff --git a/pkg/beam-cli/src/tests/fixtures.rs b/pkg/beam-cli/src/tests/fixtures.rs index 7e2dc3f..c320351 100644 --- a/pkg/beam-cli/src/tests/fixtures.rs +++ b/pkg/beam-cli/src/tests/fixtures.rs @@ -8,7 +8,7 @@ use tokio::{ use crate::{ display::ColorMode, output::OutputMode, - runtime::{BeamApp, BeamPaths, InvocationOverrides}, + runtime::{BeamApp, BeamPaths, InvocationOverrides, ensure_root_dir}, }; pub(super) async fn test_app(overrides: InvocationOverrides) -> (TempDir, BeamApp) { @@ -20,8 +20,10 @@ pub(super) async fn test_app_with_output( overrides: InvocationOverrides, ) -> (TempDir, BeamApp) { let temp_dir = TempDir::new().expect("create temp dir"); + let beam_root = temp_dir.path().join(".beam"); + ensure_root_dir(&beam_root).expect("ensure beam home"); let app = BeamApp::for_root( - BeamPaths::new(temp_dir.path().to_path_buf()), + BeamPaths::new(beam_root), ColorMode::Auto, output_mode, overrides, diff --git a/pkg/beam-cli/src/tests/fixtures/fetch_mpp_problem.json b/pkg/beam-cli/src/tests/fixtures/fetch_mpp_problem.json new file mode 100644 index 0000000..41f4e4e --- /dev/null +++ b/pkg/beam-cli/src/tests/fixtures/fetch_mpp_problem.json @@ -0,0 +1,7 @@ +{ + "type": "https://paymentauth.org/problems/payment-required", + "title": "Payment Required", + "status": 402, + "detail": "Payment is required.", + "challengeId": "challenge_123" +} diff --git a/pkg/beam-cli/src/tests/fixtures/fetch_mpp_request.json b/pkg/beam-cli/src/tests/fixtures/fetch_mpp_request.json new file mode 100644 index 0000000..210aad9 --- /dev/null +++ b/pkg/beam-cli/src/tests/fixtures/fetch_mpp_request.json @@ -0,0 +1,10 @@ +{ + "amount": "0.01", + "currency": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "recipient": "0x3333333333333333333333333333333333333333", + "decimals": 6, + "methodDetails": { + "chainId": 8453 + }, + "description": "Tempo test charge" +} diff --git a/pkg/beam-cli/src/tests/fixtures/fetch_x402_v1.json b/pkg/beam-cli/src/tests/fixtures/fetch_x402_v1.json new file mode 100644 index 0000000..b1bd72f --- /dev/null +++ b/pkg/beam-cli/src/tests/fixtures/fetch_x402_v1.json @@ -0,0 +1,12 @@ +{ + "resource": "https://api.example.com/article", + "accepts": [ + { + "scheme": "exact", + "network": "base", + "maxAmountRequired": "420000000000000", + "asset": "native", + "payTo": "0x2222222222222222222222222222222222222222" + } + ] +} diff --git a/pkg/beam-cli/src/tests/fixtures/fetch_x402_v2.json b/pkg/beam-cli/src/tests/fixtures/fetch_x402_v2.json new file mode 100644 index 0000000..d97a7e4 --- /dev/null +++ b/pkg/beam-cli/src/tests/fixtures/fetch_x402_v2.json @@ -0,0 +1,18 @@ +{ + "x402Version": 2, + "resource": { + "url": "https://api.example.com/paid", + "description": "Premium API payload", + "mimeType": "application/json" + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "amount": "10000", + "asset": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "payTo": "0x1111111111111111111111111111111111111111", + "maxTimeoutSeconds": 60 + } + ] +} diff --git a/pkg/beam-cli/src/tests/interactive_format.rs b/pkg/beam-cli/src/tests/interactive_format.rs new file mode 100644 index 0000000..ce0dcdf --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_format.rs @@ -0,0 +1,50 @@ +use super::fixtures::test_app_with_output; +use crate::{ + cli::{Command, FetchArgs}, + commands::interactive::{ParsedLine, parse_line}, + commands::interactive_parse::resolved_output_mode, + output::OutputMode, + runtime::InvocationOverrides, +}; + +#[test] +fn interactive_parser_marks_explicit_global_format_flags() { + for line in [ + "--format json fetch https://api.example.com/raw", + "--output json fetch https://api.example.com/raw", + ] { + let parsed = parse_line(line).expect("parse fetch with explicit output override"); + let ParsedLine::Cli { cli, global_flags } = parsed else { + panic!("expected clap command"); + }; + + assert_eq!(cli.output, OutputMode::Json); + assert!(global_flags.output_explicit); + } +} + +#[tokio::test] +async fn interactive_fetch_output_path_inherits_session_output_mode() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Json, InvocationOverrides::default()).await; + let parsed = parse_line("fetch --output response.bin https://api.example.com/raw") + .expect("parse fetch output path"); + let ParsedLine::Cli { cli, global_flags } = parsed else { + panic!("expected clap command"); + }; + + assert!(matches!( + &cli.command, + Some(Command::Fetch(FetchArgs { + url, + output_path, + .. + })) if url == "https://api.example.com/raw" + && output_path.as_deref() == Some("response.bin") + )); + assert!(!global_flags.output_explicit); + assert_eq!( + resolved_output_mode(global_flags, &cli, &app), + OutputMode::Json + ); +} diff --git a/pkg/beam-cli/src/tests/interactive_interrupts.rs b/pkg/beam-cli/src/tests/interactive_interrupts.rs index ad62282..9047528 100644 --- a/pkg/beam-cli/src/tests/interactive_interrupts.rs +++ b/pkg/beam-cli/src/tests/interactive_interrupts.rs @@ -2,7 +2,7 @@ use std::{ future::pending, sync::{ Arc, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }, time::Duration, }; @@ -12,7 +12,9 @@ use tokio::time::sleep; use crate::{ commands::{ interactive::parse_line, - interactive_interrupt::{InterruptOwner, run_with_interrupt_owner}, + interactive_interrupt::{ + InterruptOwner, delegate_current_interrupt_to_command, run_with_interrupt_owner, + }, }, error::{Error, Result}, }; @@ -36,6 +38,7 @@ fn interactive_non_write_commands_keep_repl_interrupts() { "balance", "call 0xabc totalSupply():(uint256)", "erc20 balance USDC", + "fetch https://api.example.com/paid", "wallets list", ] { let parsed = parse_line(line).expect("parse interactive non-write command"); @@ -57,7 +60,7 @@ async fn write_commands_ignore_repl_interrupt_wrapper() { Ok(()) } }, - async { Ok(()) }, + || async { Ok(()) }, ) .await .expect("write command should own ctrl-c"); @@ -86,7 +89,7 @@ async fn read_commands_still_use_repl_interrupt_wrapper() { pending::>().await } }, - async { + || async { sleep(Duration::from_millis(10)).await; Ok(()) }, @@ -97,3 +100,86 @@ async fn read_commands_still_use_repl_interrupt_wrapper() { assert!(matches!(err, Error::Interrupted)); assert!(dropped.load(Ordering::SeqCst)); } + +#[tokio::test] +async fn fetch_payment_flow_can_delegate_interrupts_to_command_handler() { + let parsed = parse_line("fetch https://api.example.com/paid").expect("parse fetch command"); + let ran = Arc::new(AtomicBool::new(false)); + + run_with_interrupt_owner( + parsed.interrupt_owner(), + { + let ran = Arc::clone(&ran); + async move { + let _interrupt_guard = delegate_current_interrupt_to_command(); + sleep(Duration::from_millis(20)).await; + ran.store(true, Ordering::SeqCst); + Ok(()) + } + }, + || async { + sleep(Duration::from_millis(10)).await; + Ok(()) + }, + ) + .await + .expect("delegated fetch payment flow should own ctrl-c"); + + assert!(ran.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn fetch_payment_flow_restores_repl_interrupts_after_payment_execution() { + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let parsed = parse_line("fetch https://api.example.com/paid").expect("parse fetch command"); + let dropped = Arc::new(AtomicBool::new(false)); + let entered_retry = Arc::new(AtomicBool::new(false)); + let cancel_calls = Arc::new(AtomicUsize::new(0)); + + let err = run_with_interrupt_owner( + parsed.interrupt_owner(), + { + let dropped = Arc::clone(&dropped); + let entered_retry = Arc::clone(&entered_retry); + async move { + let _guard = DropFlag(dropped); + + { + let _interrupt_guard = delegate_current_interrupt_to_command(); + sleep(Duration::from_millis(20)).await; + } + + entered_retry.store(true, Ordering::SeqCst); + pending::>().await + } + }, + { + let cancel_calls = Arc::clone(&cancel_calls); + move || { + let delay_ms = match cancel_calls.fetch_add(1, Ordering::SeqCst) { + 0 => 10, + _ => 20, + }; + + async move { + sleep(Duration::from_millis(delay_ms)).await; + Ok(()) + } + } + }, + ) + .await + .expect_err("interrupt restored fetch retry/download path"); + + assert!(matches!(err, Error::Interrupted)); + assert!(entered_retry.load(Ordering::SeqCst)); + assert!(dropped.load(Ordering::SeqCst)); + assert_eq!(cancel_calls.load(Ordering::SeqCst), 2); +} diff --git a/pkg/data/src/wallet_activity.rs b/pkg/data/src/wallet_activity.rs index 951d49a..121ecba 100644 --- a/pkg/data/src/wallet_activity.rs +++ b/pkg/data/src/wallet_activity.rs @@ -49,6 +49,7 @@ pub enum Kind { RampDepositV1, RampDepositLinkV1, RampWithdrawV1, + SwapV1, SupportV1, WalletV0, MigrateV0, diff --git a/pkg/database/migrations/2026-04-01-120000_add_payy_ramps_account/down.sql b/pkg/database/migrations/2026-04-01-120000_add_payy_ramps_account/down.sql new file mode 100644 index 0000000..4116098 --- /dev/null +++ b/pkg/database/migrations/2026-04-01-120000_add_payy_ramps_account/down.sql @@ -0,0 +1,2 @@ +DELETE FROM ramps_accounts +WHERE id = '00000000-0000-0000-0000-000000000001'::uuid; diff --git a/pkg/database/migrations/2026-04-01-120000_add_payy_ramps_account/up.sql b/pkg/database/migrations/2026-04-01-120000_add_payy_ramps_account/up.sql new file mode 100644 index 0000000..4b9cc4c --- /dev/null +++ b/pkg/database/migrations/2026-04-01-120000_add_payy_ramps_account/up.sql @@ -0,0 +1,42 @@ +INSERT INTO ramps_accounts ( + id, + address, + provider, + external_id, + kyc_status, + kyc_update_required_fields, + kyc_external_id, + country, + deposit_evm_address, + withdraw_evm_address, + metadata, + added_at, + updated_at, + kyc_delegated_id, + kyc_non_delegated_status, + wallet_id +) +SELECT + '00000000-0000-0000-0000-000000000001'::uuid, + NULL, + 'PAYY', + NULL, + 'APPROVED', + NULL, + NULL, + NULL, + NULL, + NULL, + '{"system": true, "purpose": "swap-liquidity"}'::jsonb, + NOW(), + NOW(), + NULL, + NULL, + '00000000-0000-0000-0000-000000000000'::uuid +WHERE NOT EXISTS ( + SELECT 1 + FROM ramps_accounts + WHERE wallet_id = '00000000-0000-0000-0000-000000000000'::uuid + AND provider = 'PAYY' + AND country IS NULL +); diff --git a/pkg/element/src/rand_impls.rs b/pkg/element/src/rand_impls.rs index 9464d65..f5919c7 100644 --- a/pkg/element/src/rand_impls.rs +++ b/pkg/element/src/rand_impls.rs @@ -37,10 +37,9 @@ impl Element { /// /// ```rust,compile_fail /// # use element::Element; - /// # use rand_xorshift::XorShiftRng; - /// # use rand::SeedableRng; + /// # use rand::rngs::mock::StepRng; /// // this rng is NOT cryptographically secure - /// let mut rng = XorShiftRng::from_seed([0; 16]); + /// let mut rng = StepRng::new(0, 1); /// let element = Element::random(&mut rng); /// /// println!("{element}"); // uh oh @@ -49,10 +48,9 @@ impl Element { /// Hopefully this is scary enough that we will think twice where we use this value. /// ```rust /// # use element::Element; - /// # use rand_xorshift::XorShiftRng; - /// # use rand::SeedableRng; + /// # use rand::rngs::mock::StepRng; /// // this rng is NOT cryptographically secure - /// let mut rng = XorShiftRng::from_seed([0; 16]); + /// let mut rng = StepRng::new(0, 1); /// let element = Element::random(&mut rng); /// /// println!("{}", element.get_insecure()); // works diff --git a/pkg/guild-client-http/Cargo.toml b/pkg/guild-client-http/Cargo.toml index 27b6add..f28446c 100644 --- a/pkg/guild-client-http/Cargo.toml +++ b/pkg/guild-client-http/Cargo.toml @@ -7,7 +7,9 @@ edition = "2024" guild-interface = { workspace = true } client-http = { workspace = true } client-http-longpoll = { workspace = true } +currency = { workspace = true } http-interface = { workspace = true } +price-cache-interface = { workspace = true } rpc = { workspace = true } element = { workspace = true } ramps-interface = { workspace = true } diff --git a/pkg/guild-client-http/README.md b/pkg/guild-client-http/README.md index 98b03c4..80b4a83 100644 --- a/pkg/guild-client-http/README.md +++ b/pkg/guild-client-http/README.md @@ -13,6 +13,8 @@ This package provides a specialized HTTP client for communicating with the Guild - Wallet operations - Note management - Ramps integration +- Yield/invest helpers for creating and funding Payy swap transactions, reading + aggregate yield position, and fetching token prices through Guild ## Dependency Injection diff --git a/pkg/guild-client-http/src/invest.rs b/pkg/guild-client-http/src/invest.rs new file mode 100644 index 0000000..92f3fe4 --- /dev/null +++ b/pkg/guild-client-http/src/invest.rs @@ -0,0 +1,19 @@ +use guild_interface::invest::YieldPosition; + +use crate::GuildClientHttp; + +/// Invest / yield RPC error +pub type Error = client_http::Error; + +impl GuildClientHttp { + /// Fetch aggregate invested / withdrawn totals for the authenticated user. + pub async fn get_yield_position(&self) -> Result { + self.http_client + .get("/wallets/me/invest/position") + .auth() + .exec() + .await? + .to_value() + .await + } +} diff --git a/pkg/guild-client-http/src/lib.rs b/pkg/guild-client-http/src/lib.rs index 8c8eb8b..851c173 100644 --- a/pkg/guild-client-http/src/lib.rs +++ b/pkg/guild-client-http/src/lib.rs @@ -28,12 +28,16 @@ mod auth; /// EIP-7702 client methods pub mod eip7702; mod error; +/// Invest / yield methods +pub mod invest; /// Migration methods pub mod migrate; /// Mint client methods pub mod mint; /// Note client methods pub mod note; +/// Prices methods +pub mod prices; /// Ramps methods pub mod ramps; /// Registry client methods diff --git a/pkg/guild-client-http/src/prices.rs b/pkg/guild-client-http/src/prices.rs new file mode 100644 index 0000000..736b580 --- /dev/null +++ b/pkg/guild-client-http/src/prices.rs @@ -0,0 +1,32 @@ +use client_http::serde_to_query_params; +use currency::Currency; +use guild_interface::prices::GetTokenPriceQuery; +use price_cache_interface::{TokenIdentifier, TokenPrice}; + +use crate::GuildClientHttp; + +/// Prices RPC error. +pub type Error = client_http::Error; + +impl GuildClientHttp { + /// Fetch a token price from guild. + pub async fn get_token_price( + &self, + token: &TokenIdentifier, + currency: Currency, + ) -> Result { + let query = serde_to_query_params(&GetTokenPriceQuery { currency }); + let request = match token { + TokenIdentifier::Symbol { symbol } => self + .http_client + .get(&format!("/prices/symbol/{symbol}")) + .query(query), + TokenIdentifier::Address { network, address } => self + .http_client + .get(&format!("/prices/address/{network}/{address}")) + .query(query), + }; + + request.auth().exec().await?.to_value().await + } +} diff --git a/pkg/guild-client-http/src/ramps.rs b/pkg/guild-client-http/src/ramps.rs index e931851..57fc666 100644 --- a/pkg/guild-client-http/src/ramps.rs +++ b/pkg/guild-client-http/src/ramps.rs @@ -1,8 +1,8 @@ use chrono::{DateTime, Utc}; -use client_http::{ClientResponse, NoRpcError, serde_to_query_params}; +use client_http::{ClientResponse, HttpBody, NoRpcError, serde_to_query_params}; use client_http_longpoll::{LongPoll, LongPollPoller}; use guild_interface::ramps::{ListRampsTransactionsQuery, RampTransaction}; -use ramps_interface::transaction::Transaction; +use ramps_interface::transaction::{CreateTransactionRequest, FundTransactionRequest, Transaction}; use rpc::longpoll::PollData; use uuid::Uuid; @@ -10,9 +10,47 @@ use crate::GuildClientHttp; /// Note error pub type Error = client_http::Error; +/// Typed RPC error for ramps transaction creation. +pub type CreateError = client_http::Error; +/// Typed RPC error for ramps transaction funding. +pub type FundError = client_http::Error; impl GuildClientHttp { - /// Get a list of the users notes + /// Create a new ramps transaction. + pub async fn create_transaction( + &self, + request: &CreateTransactionRequest, + ) -> Result { + self.http_client + .post("/ramps/transactions", Some(HttpBody::json(request.clone()))) + .auth() + .exec() + .await? + .to_value::() + .await + .map(RampTransaction::from) + } + + /// Fund an existing ramps transaction. + pub async fn fund_transaction( + &self, + transaction_id: Uuid, + request: &FundTransactionRequest, + ) -> Result { + self.http_client + .post( + &format!("/ramps/transactions/{transaction_id}/fund"), + Some(HttpBody::json(request.clone())), + ) + .auth() + .exec() + .await? + .to_value::() + .await + .map(RampTransaction::from) + } + + /// Get a list of the user's ramps transactions. async fn list_ramps_transactions_response( &self, query: &ListRampsTransactionsQuery, @@ -25,7 +63,7 @@ impl GuildClientHttp { .await } - /// Get a list of notes with long poll + /// Get a list of ramps transactions with long poll. #[must_use] pub fn list_ramps_transactions_long_poll( &self, diff --git a/pkg/guild-interface/Cargo.toml b/pkg/guild-interface/Cargo.toml index 8ec9b2e..011bb0b 100644 --- a/pkg/guild-interface/Cargo.toml +++ b/pkg/guild-interface/Cargo.toml @@ -9,7 +9,9 @@ contextful = { workspace = true } currency = { workspace = true } data = { workspace = true } element = { workspace = true } +network = { workspace = true } notes-interface = { workspace = true } +price-cache-interface = { workspace = true } primitives = { workspace = true } rpc = { workspace = true } rpc-error-convert = { workspace = true } diff --git a/pkg/guild-interface/README.md b/pkg/guild-interface/README.md index ef69aab..2f8bbbf 100644 --- a/pkg/guild-interface/README.md +++ b/pkg/guild-interface/README.md @@ -13,4 +13,6 @@ This package defines the interface contracts and shared data types used by the G - Request/response types - Error types - Bungee quote errors distinguish low input-value vs low output-value quotes for client handling +- Yield/invest request and response types for Payy swap creation, funding, price reads, and + aggregate position queries - Utility functions diff --git a/pkg/guild-interface/src/invest.rs b/pkg/guild-interface/src/invest.rs new file mode 100644 index 0000000..5401bbb --- /dev/null +++ b/pkg/guild-interface/src/invest.rs @@ -0,0 +1,24 @@ +use contextful::{FromContextful, InternalError}; +use element::Element; +use rpc::error::{ErrorOutput, HTTPError, TryFromHTTPError}; +use rpc_error_convert::HTTPErrorConversion; +use serde::{Deserialize, Serialize}; + +/// RPC errors for guild invest / yield operations. +#[derive( + Debug, Clone, thiserror::Error, HTTPErrorConversion, FromContextful, Serialize, Deserialize, +)] +pub enum Error { + /// Catch-all internal error wrapper. + #[error("[guild-interface/invest] internal error")] + Internal(#[from] InternalError), +} + +/// Aggregate invested / withdrawn totals used to derive all-time gains. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct YieldPosition { + /// Sum of completed USDC -> USTB swap inputs. + pub invested_total: Element, + /// Sum of completed USTB -> USDC swap outputs. + pub withdrawn_total: Element, +} diff --git a/pkg/guild-interface/src/lib.rs b/pkg/guild-interface/src/lib.rs index 30c2838..1e2b5c4 100644 --- a/pkg/guild-interface/src/lib.rs +++ b/pkg/guild-interface/src/lib.rs @@ -15,6 +15,8 @@ pub mod bungee; /// EIP-7702 interface pub mod eip7702; mod error; +/// Invest / yield interface +pub mod invest; /// Migration interface pub mod migrate; /// Mint interface @@ -23,6 +25,8 @@ pub mod mint; pub mod notes; /// Payments interface pub mod payments; +/// Prices interface +pub mod prices; /// Ramps interface pub mod ramps; /// Registry interface diff --git a/pkg/guild-interface/src/prices.rs b/pkg/guild-interface/src/prices.rs new file mode 100644 index 0000000..803efe7 --- /dev/null +++ b/pkg/guild-interface/src/prices.rs @@ -0,0 +1,46 @@ +use contextful::{FromContextful, InternalError}; +use currency::Currency; +use rpc::{ + code::ErrorCode, + error::{ErrorOutput, HTTPError, TryFromHTTPError}, +}; +use rpc_error_convert::HTTPErrorConversion; +use serde::{Deserialize, Serialize}; + +/// RPC errors for guild token-price lookups. +#[derive( + Debug, Clone, thiserror::Error, HTTPErrorConversion, FromContextful, Serialize, Deserialize, +)] +pub enum Error { + /// The requested token price is missing from guild's backing price cache. + #[not_found("prices-token-price-not-found")] + #[error("[guild-interface/prices] token price not found")] + TokenPriceNotFound, + + /// Catch-all internal error wrapper. + #[error("[guild-interface/prices] internal error")] + Internal(#[from] InternalError), +} + +/// Shared query payload for token-price lookups. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetTokenPriceQuery { + /// Quote currency for the returned price. + pub currency: Currency, +} + +/// Path payload for symbol-based token-price routes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetTokenPriceBySymbolPath { + /// Asset symbol to resolve against guild's price cache. + pub symbol: String, +} + +/// Path payload for address-based token-price routes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetTokenPriceByAddressPath { + /// Chain/network namespace for the token contract. + pub network: String, + /// Contract address for the token. + pub address: String, +} diff --git a/pkg/guild-interface/src/ramps.rs b/pkg/guild-interface/src/ramps.rs index f0a4c26..32daa40 100644 --- a/pkg/guild-interface/src/ramps.rs +++ b/pkg/guild-interface/src/ramps.rs @@ -2,6 +2,6 @@ pub use ramps_interface::transaction::{ CardStatus, ListRampsTransactionsQuery, RampCardTransaction, RampDepositLinkTransaction, - RampDepositTransaction, RampStatus, RampTransaction, RampTransactionBase, RampTransactionKind, - RampWithdrawTransaction, + RampDepositTransaction, RampStatus, RampSwapTransaction, RampTransaction, RampTransactionBase, + RampTransactionKind, RampWithdrawTransaction, SwapStatus, }; diff --git a/pkg/ramps-interface/Cargo.toml b/pkg/ramps-interface/Cargo.toml index dc5acea..1c154a9 100644 --- a/pkg/ramps-interface/Cargo.toml +++ b/pkg/ramps-interface/Cargo.toml @@ -34,6 +34,7 @@ network = { workspace = true } primitives = { workspace = true } rpc = { workspace = true } test-spy = { workspace = true } +unimock = { workspace = true } chrono = { workspace = true } contextful = { workspace = true } serde = { workspace = true } diff --git a/pkg/ramps-interface/src/account_kind.rs b/pkg/ramps-interface/src/account_kind.rs new file mode 100644 index 0000000..47b7e18 --- /dev/null +++ b/pkg/ramps-interface/src/account_kind.rs @@ -0,0 +1,28 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Identifiers describing how an account lookup was performed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow(clippy::enum_variant_names)] +pub enum AccountKind { + AccountId, + WalletId, + CardId, + ExternalId, + KycExternalId, +} + +impl fmt::Display for AccountKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + AccountKind::AccountId => "account_id", + AccountKind::WalletId => "wallet_id", + AccountKind::CardId => "card_id", + AccountKind::ExternalId => "external_id", + AccountKind::KycExternalId => "kyc_external_id", + }; + + f.write_str(label) + } +} diff --git a/pkg/ramps-interface/src/error.rs b/pkg/ramps-interface/src/error.rs index e9925fd..1f1015b 100644 --- a/pkg/ramps-interface/src/error.rs +++ b/pkg/ramps-interface/src/error.rs @@ -1,32 +1,18 @@ // lint-long-file-override allow-max-lines=300 -use std::fmt; - use contextful::{FromContextful, InternalError}; use element::Element; use kyc::{KycStatus, KycUpdateRequired}; +use network::Network; use rpc::{ HTTPErrorConversion, code::ErrorCode, error::{ErrorOutput, HTTPError, TryFromHTTPError}, }; -use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::transaction::FundingStatus; - -/// Convenience result alias for ramps operations. -pub type Result = std::result::Result; - -/// Identifiers describing how an account lookup was performed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[allow(clippy::enum_variant_names)] -pub enum AccountKind { - AccountId, - WalletId, - CardId, - ExternalId, - KycExternalId, -} +pub use crate::account_kind::AccountKind; +pub use crate::result::Result; +use crate::transaction::{FundingKind, FundingStatus}; #[derive(Debug, Error, HTTPErrorConversion, FromContextful)] pub enum Error { @@ -68,6 +54,14 @@ pub enum Error { max: Option, }, + #[error("[ramps-interface] transaction amount does not match funding note amount")] + #[failed_precondition("transaction-amount-mismatch")] + AmountMismatch { expected: Element, got: Element }, + + #[error("[ramps-interface] invalid swap amount")] + #[bad_request("invalid-swap-amount")] + InvalidSwapAmount, + #[error("[ramps-interface] invalid provider")] #[bad_request("invalid-provider")] InvalidProvider, @@ -115,6 +109,23 @@ pub enum Error { #[error("[ramps-interface] unsupported network")] UnsupportedNetwork, + #[error( + "[ramps-interface] invalid transaction kind state for from_network {from_network:?}, to_network {to_network:?}, funding_kind {funding_kind:?}" + )] + InvalidTransactionKindState { + from_network: Network, + to_network: Network, + funding_kind: FundingKind, + }, + + #[error("[ramps-interface] unsupported swap pair")] + #[bad_request("unsupported-swap-pair")] + UnsupportedSwapPair, + + #[error("[ramps-interface] transaction note kind does not match funding note kind")] + #[failed_precondition("transaction-note-kind-mismatch")] + NoteKindMismatch { expected: Element, got: Element }, + #[error("[ramps-interface] unsupported currency for provider")] UnsupportedProviderCurrency, @@ -156,6 +167,10 @@ pub enum Error { #[bad_request("payy-network-required")] PayyNetworkRequired, + #[error("[ramps-interface] payy ramps account is missing")] + #[internal("payy-account-missing")] + PayyAccountMissing, + #[error("[ramps-interface] account not found")] #[not_found("account-not-found")] AccountNotFound { kind: AccountKind, id: String }, @@ -199,6 +214,14 @@ pub enum Error { #[failed_precondition("insufficient-funds")] InsufficientFunds, + #[error("[ramps-interface] token price not found")] + #[failed_precondition("token-price-unavailable")] + TokenPriceNotFound, + + #[error("[ramps-interface] token price is stale")] + #[failed_precondition("token-price-stale")] + TokenPriceStale, + #[error("[ramps-interface] transaction can only be cancelled")] #[bad_request("transaction-can-only-be-cancelled")] TransactionCanOnlyBeCancelled, @@ -211,6 +234,10 @@ pub enum Error { #[failed_precondition("transaction-in-progress-cannot-be-cancelled")] TransactionCannotBeCancelled, + #[error("[ramps-interface] transaction cannot be funded in its current state")] + #[failed_precondition("transaction-cannot-be-funded")] + TransactionCannotBeFunded, + #[error("[ramps-interface] transaction evm address cannot be updated")] #[failed_precondition("transaction-evm-address-cannot-be-updated")] TransactionEvmAddressCannotBeUpdated, @@ -225,6 +252,14 @@ pub enum Error { #[error("[ramps-interface] declined transaction with spent notes")] DeclinedTransactionWithSpentNotes, + #[error("[ramps-interface] funding note is already spent")] + #[failed_precondition("note-already-spent")] + NoteAlreadySpent, + + #[error("[ramps-interface] funding note was not confirmed")] + #[failed_precondition("funding-note-not-confirmed")] + FundingNoteNotConfirmed, + #[error( "[ramps-interface] MCC 6012 transactions are blocked unless they are $0 Visa Provisioning Service transactions" )] @@ -243,6 +278,10 @@ pub enum Error { #[bad_request("declined-payy-transaction")] DeclinedPayyTransaction, + #[error("[ramps-interface] transaction below minimum amount of $0.10")] + #[bad_request("minimum-transaction-amount")] + MinimumTransactionAmount, + #[error("[ramps-interface] invalid auth")] #[unauthenticated("unauthorized-to-perform-action")] InvalidAuth, @@ -259,16 +298,3 @@ pub enum Error { #[error("[ramps-interface] internal error")] Internal(#[from] InternalError), } - -impl fmt::Display for AccountKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match self { - AccountKind::AccountId => "account_id", - AccountKind::WalletId => "wallet_id", - AccountKind::CardId => "card_id", - AccountKind::ExternalId => "external_id", - AccountKind::KycExternalId => "kyc_external_id", - }; - f.write_str(name) - } -} diff --git a/pkg/ramps-interface/src/lib.rs b/pkg/ramps-interface/src/lib.rs index 72825a8..b47b97e 100644 --- a/pkg/ramps-interface/src/lib.rs +++ b/pkg/ramps-interface/src/lib.rs @@ -6,6 +6,7 @@ //! HTTP layer (`ramps-rpc`) and business logic implementations. pub mod account; +mod account_kind; pub mod admin; pub mod document; pub mod error; @@ -13,6 +14,8 @@ pub mod event; pub mod method; pub mod provider; pub mod quote; +mod result; +pub mod swap_pricer; pub mod transaction; pub mod util; pub mod webhooks; @@ -21,13 +24,16 @@ pub mod webhooks; mod tests; pub use account::*; +pub use account_kind::AccountKind; pub use admin::*; pub use document::*; -pub use error::{Error, Result}; +pub use error::Error; pub use event::*; pub use method::*; pub use provider::*; pub use quote::*; +pub use result::Result; +pub use swap_pricer::*; pub use transaction::*; pub use util::*; pub use webhooks::*; diff --git a/pkg/ramps-interface/src/provider.rs b/pkg/ramps-interface/src/provider.rs index f8c05e5..0ab188d 100644 --- a/pkg/ramps-interface/src/provider.rs +++ b/pkg/ramps-interface/src/provider.rs @@ -39,6 +39,7 @@ use crate::error::{Error, Result}; pub enum Provider { Alfred, Manteca, + Payy, Rain, Sumsub, Cybrid, diff --git a/pkg/ramps-interface/src/result.rs b/pkg/ramps-interface/src/result.rs new file mode 100644 index 0000000..b7d62c1 --- /dev/null +++ b/pkg/ramps-interface/src/result.rs @@ -0,0 +1,4 @@ +use crate::Error; + +/// Convenience result alias for ramps operations. +pub type Result = std::result::Result; diff --git a/pkg/ramps-interface/src/swap_pricer.rs b/pkg/ramps-interface/src/swap_pricer.rs new file mode 100644 index 0000000..b4b5db8 --- /dev/null +++ b/pkg/ramps-interface/src/swap_pricer.rs @@ -0,0 +1,19 @@ +use async_trait::async_trait; +use element::Element; +use unimock::unimock; + +use crate::Result; + +/// Converts one in-rollup note amount into another using live token pricing. +#[unimock(api = SwapPricerMock)] +#[async_trait] +pub trait SwapPricer: Send + Sync { + /// Convert an amount of `from_note_kind` into the equivalent amount of + /// `to_note_kind`, rounding down in favor of the system. + async fn convert( + &self, + from_note_kind: &Element, + to_note_kind: &Element, + from_amount: Element, + ) -> Result; +} diff --git a/pkg/ramps-interface/src/tests.rs b/pkg/ramps-interface/src/tests.rs index 7418338..6bdebd3 100644 --- a/pkg/ramps-interface/src/tests.rs +++ b/pkg/ramps-interface/src/tests.rs @@ -1,6 +1,7 @@ +use network::Network; use rpc::{code::ErrorCode, error::HTTPError}; -use crate::Error; +use crate::{Error, FundingKind, Transaction}; #[test] fn invalid_auth_keeps_legacy_unauthorized_reason() { @@ -29,3 +30,22 @@ fn permission_denied_uses_permission_denied_reason() { "[ramps-interface] permission denied: admin token lacks required scope" ); } + +#[test] +fn try_kind_rejects_invalid_network_state() { + let transaction = Transaction { + from_network: Network::Polygon, + to_network: Network::Ethereum, + funding_kind: FundingKind::Crypto, + ..Transaction::default() + }; + + assert!(matches!( + transaction.try_kind(), + Err(Error::InvalidTransactionKindState { + from_network: Network::Polygon, + to_network: Network::Ethereum, + funding_kind: FundingKind::Crypto, + }) + )); +} diff --git a/pkg/ramps-interface/src/transaction/db.rs b/pkg/ramps-interface/src/transaction/db.rs index 59bbe2e..8333858 100644 --- a/pkg/ramps-interface/src/transaction/db.rs +++ b/pkg/ramps-interface/src/transaction/db.rs @@ -14,7 +14,7 @@ use zk_primitives::bridged_polygon_usdc_note_kind; #[cfg(feature = "diesel")] use crate::derive_pg_text_enum; -use crate::provider::Provider; +use crate::{Error, Result, provider::Provider}; use super::{Category, FundingStatus, Status, TransactionStatusReason}; @@ -40,6 +40,7 @@ pub struct Transaction { pub provider: Provider, pub external_id: Option, pub external_fund_id: Option, + pub local_id: Option, pub status: Status, pub funding_status: Option, pub funding_kind: FundingKind, @@ -81,6 +82,7 @@ impl Default for Transaction { provider: Provider::Alfred, external_id: None, external_fund_id: None, + local_id: None, status: Status::Pending, funding_status: None, funding_kind: FundingKind::Crypto, @@ -113,11 +115,13 @@ impl Default for Transaction { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TransactionKind { Deposit, DepositLink, Withdraw, Card, + Swap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -161,17 +165,38 @@ impl Transaction { #[must_use] pub fn kind(&self) -> TransactionKind { + match self.try_kind() { + Ok(kind) => kind, + Err(err) => unreachable!("{err}"), + } + } + + /// Returns the derived transaction kind for the current persisted state. + /// + /// # Errors + /// + /// Returns [`crate::Error::InvalidTransactionKindState`] when the network + /// and funding-kind combination does not map to a valid ramps transaction + /// kind. + pub fn try_kind(&self) -> Result { match (self.from_network, self.to_network) { - (Network::Card, _) | (_, Network::Card) => TransactionKind::Card, - (Network::Payy, _) => TransactionKind::Withdraw, + (Network::Card, _) | (_, Network::Card) => Ok(TransactionKind::Card), + (Network::Payy, Network::Payy) => Ok(TransactionKind::Swap), + (Network::Payy, _) => Ok(TransactionKind::Withdraw), (_, Network::Payy) => match self.funding_kind { - FundingKind::Crypto => TransactionKind::Deposit, - FundingKind::Link => TransactionKind::DepositLink, - FundingKind::UserRemoteNotes => { - unreachable!("invalid funding kind UserRemoteNotes for deposit") - } + FundingKind::Crypto => Ok(TransactionKind::Deposit), + FundingKind::Link => Ok(TransactionKind::DepositLink), + FundingKind::UserRemoteNotes => Err(Error::InvalidTransactionKindState { + from_network: self.from_network, + to_network: self.to_network, + funding_kind: self.funding_kind, + }), }, - _ => unreachable!("one of to_network, from_network must be Network::Payy"), + _ => Err(Error::InvalidTransactionKindState { + from_network: self.from_network, + to_network: self.to_network, + funding_kind: self.funding_kind, + }), } } diff --git a/pkg/ramps-interface/src/transaction/kinds/mod.rs b/pkg/ramps-interface/src/transaction/kinds/mod.rs index ca32f5e..e9e308c 100644 --- a/pkg/ramps-interface/src/transaction/kinds/mod.rs +++ b/pkg/ramps-interface/src/transaction/kinds/mod.rs @@ -13,10 +13,12 @@ use super::{Status, Transaction, TransactionKind}; mod card; mod deposit; +mod swap; mod withdraw; pub use card::*; pub use deposit::*; +pub use swap::*; pub use withdraw::*; #[derive(Debug, Clone, Deserialize, Serialize)] @@ -34,6 +36,7 @@ pub enum RampTransactionKind { Withdraw(RampWithdrawTransaction), Deposit(RampDepositTransaction), DepositLink(RampDepositLinkTransaction), + Swap(RampSwapTransaction), } impl From for RampTransaction { @@ -43,6 +46,7 @@ impl From for RampTransaction { TransactionKind::Withdraw => RampTransactionKind::Withdraw(txn.clone().into()), TransactionKind::Deposit => RampTransactionKind::Deposit(txn.clone().into()), TransactionKind::DepositLink => RampTransactionKind::DepositLink(txn.clone().into()), + TransactionKind::Swap => RampTransactionKind::Swap(txn.clone().into()), }; Self { diff --git a/pkg/ramps-interface/src/transaction/kinds/swap.rs b/pkg/ramps-interface/src/transaction/kinds/swap.rs new file mode 100644 index 0000000..9167475 --- /dev/null +++ b/pkg/ramps-interface/src/transaction/kinds/swap.rs @@ -0,0 +1,48 @@ +use element::Element; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use super::{Status, Transaction}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export))] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum SwapStatus { + Pending, + Funded, + Complete, + Failed, +} + +impl From for SwapStatus { + fn from(status: Status) -> Self { + match status { + Status::Pending => Self::Pending, + Status::Funded => Self::Funded, + Status::Complete => Self::Complete, + Status::Failed => Self::Failed, + other => panic!("unexpected status {other:?} for swap txn"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export))] +pub struct RampSwapTransaction { + pub status: SwapStatus, + pub from_note_kind: Element, + pub to_note_kind: Element, +} + +impl From for RampSwapTransaction { + fn from(txn: Transaction) -> Self { + Self { + status: txn.status.into(), + from_note_kind: txn.from_note_kind(), + to_note_kind: txn.to_note_kind(), + } + } +} diff --git a/pkg/ramps-interface/src/transaction/rpc.rs b/pkg/ramps-interface/src/transaction/rpc.rs index aafe728..9ee9f70 100644 --- a/pkg/ramps-interface/src/transaction/rpc.rs +++ b/pkg/ramps-interface/src/transaction/rpc.rs @@ -3,13 +3,22 @@ use element::Element; use network::Network; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use zk_primitives::NoteURLPayload; use crate::provider::Provider; use super::{FundingStatus, Status, TransactionUpdate}; #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct CreateTransactionRequest { +#[serde(untagged)] +pub enum CreateTransactionRequest { + Remote(Box), + Swap(CreateSwapTransactionRequest), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct CreateRemoteTransactionRequest { pub quote_id: Uuid, pub from_network_identifier: Option, pub to_network_identifier: Option, @@ -17,6 +26,17 @@ pub struct CreateTransactionRequest { pub external_id: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct CreateSwapTransactionRequest { + pub from_network: Network, + pub to_network: Network, + pub from_note_kind: Element, + pub to_note_kind: Element, + pub from_amount: Element, + pub local_id: String, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UpdateTransactionRequest { pub status: Option, @@ -35,7 +55,13 @@ impl From for TransactionUpdate { } } +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct FundTransactionRequest { + #[serde(flatten)] + pub note: NoteURLPayload, +} + +pub struct FundRemoteTransactionRequest { pub external_id: String, pub from_currency: Currency, pub from_amount: Element, diff --git a/pkg/ramps-interface/src/transaction/traits.rs b/pkg/ramps-interface/src/transaction/traits.rs index 153f0f4..0bb9323 100644 --- a/pkg/ramps-interface/src/transaction/traits.rs +++ b/pkg/ramps-interface/src/transaction/traits.rs @@ -1,15 +1,15 @@ use async_trait::async_trait; -use test_spy::spy_mock; +use unimock::unimock; use uuid::Uuid; use crate::error::Result; use super::{ - CreateTransactionRequest, LimitQuery, ListRampsTransactionsQuery, RampTransaction, - RemainingLimits, Transaction, UpdateTransactionRequest, + CreateTransactionRequest, FundTransactionRequest, LimitQuery, ListRampsTransactionsQuery, + RampTransaction, RemainingLimits, Transaction, UpdateTransactionRequest, }; -#[spy_mock] +#[unimock(api = TransactionsInterfaceMock)] #[async_trait] pub trait TransactionsInterface: Send + Sync { async fn create_transaction( @@ -33,5 +33,12 @@ pub trait TransactionsInterface: Send + Sync { request: UpdateTransactionRequest, ) -> Result; + async fn fund_transaction( + &self, + wallet_id: Uuid, + transaction_id: Uuid, + request: FundTransactionRequest, + ) -> Result; + async fn get_limits(&self, wallet_id: Uuid, query: LimitQuery) -> Result; } diff --git a/pkg/wallet-data-dep/src/kinds/swap.rs b/pkg/wallet-data-dep/src/kinds/swap.rs index a3b9e42..9b1667d 100644 --- a/pkg/wallet-data-dep/src/kinds/swap.rs +++ b/pkg/wallet-data-dep/src/kinds/swap.rs @@ -1,24 +1,49 @@ use element::Element; use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::WalletActivityTxnStage; -// Swap Activity Types #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "stage", content = "data", rename_all = "lowercase")] -pub enum WalletActivitySwapStage { - Init(SwapInitData), - Success(SwapSuccessData), +#[serde(rename_all = "camelCase")] +pub struct WalletActivitySwapData { + pub from_amount: Element, + pub from_note_kind: Element, + pub to_note_kind: Element, + pub txn_id: Option, + pub private_key: Element, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct SwapInitData { - pub new_primary_key: Element, - pub value: Element, +pub struct WalletActivitySwapFundData { + #[serde(flatten)] + pub swap: WalletActivitySwapData, + pub txn: Box, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct SwapSuccessData { - pub new_primary_key: Option<()>, - pub value: Element, +pub struct WalletActivitySwapSuccessData { + #[serde(flatten)] + pub swap: WalletActivitySwapData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WalletActivitySwapFailData { + #[serde(flatten)] + pub swap: WalletActivitySwapData, + pub error: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "stage", content = "data", rename_all = "camelCase")] +pub enum WalletActivitySwapStage { + CreateSwapTxn(WalletActivitySwapData), + FundSwap(WalletActivitySwapFundData), + SubmitNote(WalletActivitySwapData), + WaitForCredit(WalletActivitySwapData), + Success(WalletActivitySwapSuccessData), + Fail(WalletActivitySwapFailData), } diff --git a/pkg/xtask/src/lint/mod.rs b/pkg/xtask/src/lint/mod.rs index 2b8fe69..467cf53 100644 --- a/pkg/xtask/src/lint/mod.rs +++ b/pkg/xtask/src/lint/mod.rs @@ -14,7 +14,7 @@ use crate::lint::steps::{ /// Available linter types #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum LinterType { - /// GENERATED_AI_GUIDANCE.md regeneration + /// CLAUDE.md regeneration ClaudeGuidelines, /// Rust formatter Rustfmt, diff --git a/pkg/xtask/src/lint/steps/claude.rs b/pkg/xtask/src/lint/steps/claude.rs index 3843aef..8eef289 100644 --- a/pkg/xtask/src/lint/steps/claude.rs +++ b/pkg/xtask/src/lint/steps/claude.rs @@ -11,17 +11,15 @@ use crate::error::Result; use crate::lint::LintMode; use crate::lint::steps::StepResult; -const CANONICAL_FILE: &str = "GENERATED_AI_GUIDANCE.md"; - pub fn run_claude_doc(repo_root: &Path, mode: LintMode) -> Result { let start = Instant::now(); - let script_path = repo_root.join("GENERATED_AI_GUIDANCE.sh"); - let target_path = repo_root.join(CANONICAL_FILE); + let script_path = repo_root.join("CLAUDE.md.sh"); + let target_path = repo_root.join("CLAUDE.md"); if !script_path.is_file() { return Ok(StepResult::failed( - CANONICAL_FILE, - "GENERATED_AI_GUIDANCE.sh was not found in the repository root".to_string(), + "CLAUDE.md", + "CLAUDE.md.sh was not found in the repository root".to_string(), start.elapsed(), )); } @@ -35,8 +33,8 @@ pub fn run_claude_doc(repo_root: &Path, mode: LintMode) -> Result { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let step = StepResult::failed( - CANONICAL_FILE, - "GENERATED_AI_GUIDANCE.sh exited with a non-zero status code".to_string(), + "CLAUDE.md", + "CLAUDE.md.sh exited with a non-zero status code".to_string(), start.elapsed(), ); return if stderr.is_empty() { @@ -59,49 +57,36 @@ pub fn run_claude_doc(repo_root: &Path, mode: LintMode) -> Result { if needs_update { fs::write(&target_path, &generated) .with_context(|| format!("write {}", target_path.display()))?; + Ok(StepResult::fixed( - CANONICAL_FILE, - format!( - "Regenerated {} from GENERATED_AI_GUIDANCE.sh", - CANONICAL_FILE - ), - vec![CANONICAL_FILE.to_owned()], + "CLAUDE.md", + "Regenerated CLAUDE.md from CLAUDE.md.sh".to_string(), + vec!["CLAUDE.md".to_owned()], start.elapsed(), )) } else { Ok(StepResult::success( - CANONICAL_FILE, - format!( - "{} already matches GENERATED_AI_GUIDANCE.sh", - CANONICAL_FILE - ), + "CLAUDE.md", + "CLAUDE.md already matches CLAUDE.md.sh".to_string(), start.elapsed(), )) } } LintMode::CheckOnly => match existing { Some(bytes) if bytes == generated => Ok(StepResult::success( - CANONICAL_FILE, - format!( - "{} already matches GENERATED_AI_GUIDANCE.sh", - CANONICAL_FILE - ), + "CLAUDE.md", + "CLAUDE.md already matches CLAUDE.md.sh".to_string(), start.elapsed(), )), Some(_) => Ok(StepResult::failed( - CANONICAL_FILE, - format!( - "{} differs from GENERATED_AI_GUIDANCE.sh output. Re-run cargo xtask lint --fix.", - CANONICAL_FILE - ), + "CLAUDE.md", + "CLAUDE.md differs from CLAUDE.md.sh output. Re-run cargo xtask lint --fix." + .to_string(), start.elapsed(), )), None => Ok(StepResult::failed( - CANONICAL_FILE, - format!( - "{} is missing; run cargo xtask lint --fix to regenerate it.", - CANONICAL_FILE - ), + "CLAUDE.md", + "CLAUDE.md is missing; run cargo xtask lint --fix to regenerate it.".to_string(), start.elapsed(), )), }, diff --git a/pkg/xtask/src/setup/mod.rs b/pkg/xtask/src/setup/mod.rs index 7a5e36d..68051fc 100644 --- a/pkg/xtask/src/setup/mod.rs +++ b/pkg/xtask/src/setup/mod.rs @@ -1,7 +1,6 @@ // lint-long-file-override allow-max-lines=300 use std::env; use std::fs; -use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::process::Output; @@ -97,13 +96,6 @@ pub fn run_setup(args: SetupArgs) -> Result<()> { fixtures::ensure_params(&repo_root)?; - let claude_md = repo_root.join("CLAUDE.md"); - if fs::symlink_metadata(&claude_md).is_err() { - symlink("GENERATED_AI_GUIDANCE.md", &claude_md) - .with_context(|| "create CLAUDE.md symlink".to_string())?; - eprintln!("Created CLAUDE.md -> GENERATED_AI_GUIDANCE.md"); - } - if args.skip_eth { eprintln!("Skipping eth dependency installation (requested)"); } else { diff --git a/pkg/zk-primitives/README.md b/pkg/zk-primitives/README.md index bb982ad..fb001e9 100644 --- a/pkg/zk-primitives/README.md +++ b/pkg/zk-primitives/README.md @@ -14,3 +14,5 @@ This package provides the fundamental zero-knowledge cryptographic primitives. - Note management - Address utilities - Aggregation circuits +- Note-kind helpers for bridged assets, including Ethereum USTB, plus utilities + used by swap pricing to recover token network/address metadata from note kinds diff --git a/pkg/zk-primitives/src/util.rs b/pkg/zk-primitives/src/util.rs index aff9230..1e1d6bd 100644 --- a/pkg/zk-primitives/src/util.rs +++ b/pkg/zk-primitives/src/util.rs @@ -145,6 +145,16 @@ pub fn extract_chain_id_from_note_kind(note_kind: Element) -> u64 { u64::from_be_bytes(chain_bytes) } +/// Extracts the bridged token address from a note kind element. +/// +/// The bridge note kind uses the format ``. +/// This helper copies the address bytes (index 10 through 29) into an `H160`. +#[must_use] +pub fn extract_address_from_note_kind(note_kind: Element) -> H160 { + let note_bytes = note_kind.to_be_bytes(); + H160::from_slice(¬e_bytes[10..30]) +} + /// Generates a note kind element for USDC on Polygon network. /// Uses the standard bridged asset format for USDC token on Polygon chain. /// @@ -366,4 +376,13 @@ mod tests { assert_eq!(extract_chain_id_from_note_kind(note_kind), chain); } + + #[test] + fn test_extract_address_from_note_kind() { + let address = + H160::from_slice(&hex::decode("43415eb6ff9db7e26a15b704e7a3edce97d31c4e").unwrap()); + let note_kind = generate_note_kind_bridge_evm(1, address); + + assert_eq!(extract_address_from_note_kind(note_kind), address); + } }