From 922d56d3951367c54a2e21b7757bdadc13ed0b9c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 12:56:02 +0000 Subject: [PATCH 01/13] feat(markets): Bitcoin difficulty betting with hidden L2 stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire Fund (Atomiq Lightning → BTC) and Bet (lognormal prepare + fee/approve/ execute_trade via NostrAccount BIP-340 and AVNU sponsored paymaster proxy). - Lognormal hints: both denoms = isqrt(2σ√π) (same limbs); UI axis is raw D, candidate μ = ln(D); place_bet never mixes Lightning - buzz-avnu-proxy: server-side AVNU_API_KEY via env; Dockerfile ships binary - INDEXER_URL product host https://markets.bitcoinmarkets.app (no loopback) - Tests: 10 bps fee (min 1 sat), halt-at-height, agent keys excluded Co-authored-by: Adrien Lacombe --- .env.example | 15 + AGENTS.md | 2 +- Cargo.lock | 15 + Cargo.toml | 1 + Dockerfile | 18 +- crates/buzz-avnu-proxy/Cargo.toml | 23 + crates/buzz-avnu-proxy/src/main.rs | 203 ++++++ crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/src/markets.rs | 218 ++++++ desktop/package.json | 9 +- desktop/src-tauri/src/commands/markets.rs | 465 +++++++++++++ desktop/src-tauri/src/commands/mod.rs | 4 +- desktop/src-tauri/src/lib.rs | 3 + desktop/src/app/AppShell.helpers.ts | 8 + desktop/src/app/AppShell.tsx | 2 + .../src/app/navigation/useAppNavigation.ts | 12 + desktop/src/app/routeTree.gen.ts | 21 + desktop/src/app/routes.ts | 1 + desktop/src/app/routes/markets.tsx | 21 + desktop/src/features/markets/lib/constants.ts | 42 ++ desktop/src/features/markets/lib/fee.ts | 22 + desktop/src/features/markets/lib/feeCall.ts | 19 + .../src/features/markets/lib/fundLightning.ts | 111 ++++ desktop/src/features/markets/lib/halt.ts | 20 + desktop/src/features/markets/lib/indexer.ts | 64 ++ .../features/markets/lib/lognormalHints.ts | 60 ++ .../src/features/markets/lib/markets.test.mjs | 92 +++ desktop/src/features/markets/lib/placeBet.ts | 65 ++ .../markets/lib/prepareLognormalTrade.ts | 217 ++++++ .../src/features/markets/ui/MarketsScreen.tsx | 255 +++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 4 + .../sidebar/ui/AppSidebarPinnedHeader.tsx | 15 + docs/bitcoin-markets.md | 61 ++ infra/aws/markets.tf.md | 19 + pnpm-lock.yaml | 629 ++++++++++++++++++ 35 files changed, 2729 insertions(+), 9 deletions(-) create mode 100644 crates/buzz-avnu-proxy/Cargo.toml create mode 100644 crates/buzz-avnu-proxy/src/main.rs create mode 100644 crates/buzz-core/src/markets.rs create mode 100644 desktop/src-tauri/src/commands/markets.rs create mode 100644 desktop/src/app/routes/markets.tsx create mode 100644 desktop/src/features/markets/lib/constants.ts create mode 100644 desktop/src/features/markets/lib/fee.ts create mode 100644 desktop/src/features/markets/lib/feeCall.ts create mode 100644 desktop/src/features/markets/lib/fundLightning.ts create mode 100644 desktop/src/features/markets/lib/halt.ts create mode 100644 desktop/src/features/markets/lib/indexer.ts create mode 100644 desktop/src/features/markets/lib/lognormalHints.ts create mode 100644 desktop/src/features/markets/lib/markets.test.mjs create mode 100644 desktop/src/features/markets/lib/placeBet.ts create mode 100644 desktop/src/features/markets/lib/prepareLognormalTrade.ts create mode 100644 desktop/src/features/markets/ui/MarketsScreen.tsx create mode 100644 docs/bitcoin-markets.md create mode 100644 infra/aws/markets.tf.md diff --git a/.env.example b/.env.example index 0f7bbba6f13..aee5c34a8b9 100644 --- a/.env.example +++ b/.env.example @@ -250,3 +250,18 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_TERMS_OF_SERVICE_MARKDOWN="# Terms of Service\n\nFull terms here." # BUZZ_PRIVACY_POLICY_MARKDOWN="# Privacy Policy\n\nFull policy here." # BUZZ_AGE_ATTESTATION_REQUIRED=true + +# ── Bitcoin Markets (fork) ─────────────────────────────────────────────────── +# Market indexer base URL. Production host (required for deploy): +# INDEXER_URL=https://markets.bitcoinmarkets.app +# Listing: GET $INDEXER_URL/api/markets and GET $INDEXER_URL/health +# Do NOT default this to http://127.0.0.1:8787 — loopback is listing-proof only. +INDEXER_URL=https://markets.bitcoinmarkets.app + +# Server-side AVNU SNIP-29 paymaster proxy (buzz-avnu-proxy). +# The API key must NEVER appear in the repo, frontend, or Tauri binary. +# AVNU_API_KEY= +# AVNU_PAYMASTER_URL=https://starknet.paymaster.avnu.fi +# BIND_ADDR=0.0.0.0:8788 +# Desktop talks to the proxy (not AVNU directly): +# AVNU_PROXY_URL=http://127.0.0.1:8788 diff --git a/AGENTS.md b/AGENTS.md index ae2a720fe79..defe79fdcbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,7 +183,7 @@ place. | `migrations/0029_channels_id_lookup_index.sql`, `0030_long_reaction_payloads.sql`, `0031_community_deletion.sql`, `0032_community_deletion_recovery.sql`, `0033_workflow_run_error_codes.sql` | upstream's `0027_channels_id_lookup_index.sql`, `0028_long_reaction_payloads.sql`, `0029_community_deletion.sql`, `0030_community_deletion_recovery.sql` and `0031_workflow_run_error_codes.sql`, **renumbered**; contents byte-identical | The fork holds 0027 and 0028, so upstream's own new migrations have to arrive above them. Five syncs running, and it has now fired on every sync that touched `migrations/` — treat this as the standing cost of the fork's migration block rather than a special case. See [Upstream migrations arrive renumbered](#upstream-migrations-arrive-renumbered) | | `crates/buzz-db/src/migration.rs` | `migrations.len()` assertion is 33, not upstream's 31; upstream's channel-index assertion reads `migrations[28].version == 29`, long-reaction `migrations[29].version == 30`, deletion `migrations[30].version == 31`, deletion-recovery `migrations[31].version == 32`, workflow-error-codes `migrations[32].version == 33`; and `deletion_surface_parity_between_migration_0029_and_schema_sql` looks up `version == 31` | Counts embedded migrations, so it moves whenever *either* side adds one. `0027` landed without bumping it and left the test failing on `main`; fixed in PR #9. Beyond the count, every upstream assertion that indexes `migrations[…]` past 25 or names a version above 26 has to be shifted by the fork's two — see the section below for why the test suite will *not* catch it if you forget. **The highest-applied-version assertion is no longer a fork patch**: upstream's 2026-08-13 range replaced the hardcoded `Some(30)` with a `latest_version` derived from `MIGRATOR`, so it now tracks the renumber on its own — take upstream's version if it ever conflicts again | | `.github/workflows/macos-canary.yml` | new; `push` trigger on `main` with desktop path filters | Unsigned macOS canary; upstream only has a *signed* one, which a fork cannot run. Builds automatically when `desktop/**`, `crates/**` or the root `Cargo.*` change, so the newest artifact always matches `main` — it was dispatch-only, and the sole artifact went 13 commits stale. Free: the repo is public, so GitHub-hosted macOS runners are unbilled. Stages the artifact and the usage notes under the product name read from `tauri.conf.json`, not a hardcoded one, so the brand rename below cannot publish a build under the old name. Sets `signingIdentity: "-"` in its inline config and runs **without** `--no-sign`, which would silently discard it; asserts the bundle signature of the `.app` inside the mounted DMG. Its **sidecar list must track upstream's non-Windows lanes**: `tauri.conf.json`'s `externalBin` is shared, and `scripts/bundle-sidecars.sh` exits 1 on a missing binary, so a sidecar upstream adds breaks this workflow without ever conflicting — `buzz-backend-kubernetes` (#4289) did exactly that in the 2026-08-03 sync | -| `Dockerfile` | `buzz-paymaster` added to the cargo build, the strip step, and both `COPY` stages | The sponsor ships in the relay's image so there is one publish pipeline and one immutable `:sha-<7>` tag for `deploy-aws.yml` to pin. Four one-line additions, each inside an existing parallel list, so a conflict resolves as *keep ours, take upstream's*. It is **not** the `ENTRYPOINT` — `infra/aws/paymaster.tf` overrides `entryPoint` | +| `Dockerfile` | `buzz-paymaster` and `buzz-avnu-proxy` added to the cargo build, the strip step, and both `COPY` stages | The sponsor and the AVNU API-key proxy ship in the relay's image so there is one publish pipeline and one immutable `:sha-<7>` tag for `deploy-aws.yml` to pin. Four-to-eight one-line additions, each inside an existing parallel list, so a conflict resolves as *keep ours, take upstream's*. Neither is the `ENTRYPOINT` — ECS tasks override `command`. `AVNU_API_KEY` is injected at runtime from secrets, never baked into the image | | `Cargo.toml`, `crates/buzz-core/Cargo.toml`, `crates/buzz-cli/Cargo.toml`, `crates/buzz-core/src/lib.rs`, `crates/buzz-cli/src/lib.rs`, `crates/buzz-cli/src/commands/mod.rs`, `crates/buzz-cli/TESTING.md` | Starknet/paymaster wiring: `crates/buzz-paymaster` joins the workspace members; `starknet-crypto`/`starknet-core`/`starknet-providers`/`starknet-accounts`/`secp256k1`/`async-trait` deps; `pub mod` lines for `outside_execution`, `sponsorship`, `starknet_account`, `commands::wallet`; the `WalletCmd` enum and its dispatch | The declaration surface for [Starknet accounts](#starknet-accounts) and [sponsorship](#sponsorship-buzz-paymaster). Each is a one-to-few-line insertion into an existing alphabetical list, so conflicts resolve as *keep ours, take upstream's*. **None carries an in-file `FORK-LOCAL` marker** — a manifest list takes a comment badly and the module lines are self-evidently fork-local from the module names — so this row is the only record. The new deps are all genuinely reachable: `secp256k1` from `starknet_account::sign_tx_hash` and `starknet_factory`, the `starknet-*` crates from `buzz-paymaster` and `wallet.rs`. `buzz-core` keeps its "zero I/O dependencies" contract — none of these pull tokio/sqlx/redis/axum | | `crates/buzz-relay/src/handlers/ingest.rs` | `KIND_SPONSOR_REQUEST`, `KIND_SPONSOR_RESULT` and `KIND_SPONSOR_DEPLOY_REQUEST` added to the `required_scope_for_kind` `UsersWrite` arm and to `is_global_only_kind` | The relay half of [sponsorship](#sponsorship-buzz-paymaster). `UsersWrite` because sponsorship is a user acting on their own Starknet account; global-only because that account belongs to the author and never to a channel. Both hunks land inside long `|`-separated match arms that upstream extends regularly, so expect conflicts here whenever upstream adds a kind — resolve as *keep our three, take upstream's* | | `CHANGELOG.md` | fork-test release notes appended at the **bottom** | `release.yml`'s "Create or verify versioned draft" step greps `^## v$` anywhere in the file, so position does not affect the gate — while upstream only ever prepends at the top. Anchoring fork entries at the bottom keeps a file upstream rewrites every release from conflicting on every sync. **Add new fork-test versions below, never above** | diff --git a/Cargo.lock b/Cargo.lock index 3bc89723a8b..081bac6c05f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,6 +986,21 @@ dependencies = [ "uuid 1.23.1", ] +[[package]] +name = "buzz-avnu-proxy" +version = "0.1.0" +dependencies = [ + "axum", + "reqwest 0.13.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + [[package]] name = "buzz-backend-kubernetes" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ed6fe20945d..0ac8115f914 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/buzz-workflow", "crates/buzz-media", "crates/buzz-paymaster", + "crates/buzz-avnu-proxy", "crates/buzz-cli", "crates/buzz-pairing-cli", "crates/buzz-sdk", diff --git a/Dockerfile b/Dockerfile index 2fa85e6ff86..20e268cb479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,15 +67,18 @@ COPY --from=planner /build/recipe.json recipe.json # scoping to -p buzz-relay misses transitive deps and re-builds them later. RUN cargo chef cook --release --recipe-path recipe.json COPY . . -# FORK-LOCAL PATCH (adrienlacombe/buzz): buzz-paymaster ships in this image rather -# than one of its own. A second image would need a second publish workflow, and -# deploy-aws.yml pins the commit's immutable :sha-<7> tag — so it would have to wait -# on both pipelines or fall back to a floating tag, losing the property the deploy -# depends on. One line here is cheaper than that. The paymaster is not the ENTRYPOINT; -# its ECS task overrides `command`. +# FORK-LOCAL PATCH (adrienlacombe/buzz): buzz-paymaster and buzz-avnu-proxy ship +# in this image rather than ones of their own. A second image would need a second +# publish workflow, and deploy-aws.yml pins the commit's immutable :sha-<7> tag — +# so it would have to wait on both pipelines or fall back to a floating tag, +# losing the property the deploy depends on. Neither is the ENTRYPOINT; their +# ECS tasks override `command`. +# buzz-avnu-proxy holds AVNU_API_KEY server-side — never bake the key into the +# image; inject it at runtime from secrets. RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ -p buzz-paymaster --bin buzz-paymaster \ + -p buzz-avnu-proxy --bin buzz-avnu-proxy \ -p buzz-pair-relay --bin buzz-pair-relay # Derive the normal release binaries from the same optimized ELF files as the @@ -84,6 +87,7 @@ FROM builder AS stripped-binaries RUN strip target/release/buzz-relay \ && strip target/release/buzz-admin \ && strip target/release/buzz-paymaster \ + && strip target/release/buzz-avnu-proxy \ && strip target/release/buzz-pair-relay # ─── Stage 4: web bundle (pnpm + vite) ────────────────────────────────────── @@ -178,6 +182,7 @@ COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admin # FORK-LOCAL PATCH (adrienlacombe/buzz): see the cargo build step above. COPY --from=builder /build/target/release/buzz-paymaster /usr/local/bin/buzz-paymaster +COPY --from=builder /build/target/release/buzz-avnu-proxy /usr/local/bin/buzz-avnu-proxy COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay # Keep the stripped runtime as the final/default Dockerfile target so existing @@ -187,4 +192,5 @@ COPY --from=stripped-binaries /build/target/release/buzz-relay /usr/local/bin/bu COPY --from=stripped-binaries /build/target/release/buzz-admin /usr/local/bin/buzz-admin # FORK-LOCAL PATCH (adrienlacombe/buzz): see the cargo build step above. COPY --from=stripped-binaries /build/target/release/buzz-paymaster /usr/local/bin/buzz-paymaster +COPY --from=stripped-binaries /build/target/release/buzz-avnu-proxy /usr/local/bin/buzz-avnu-proxy COPY --from=stripped-binaries /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay diff --git a/crates/buzz-avnu-proxy/Cargo.toml b/crates/buzz-avnu-proxy/Cargo.toml new file mode 100644 index 00000000000..050a6e34b0e --- /dev/null +++ b/crates/buzz-avnu-proxy/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "buzz-avnu-proxy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Server-side AVNU SNIP-29 paymaster proxy (API key stays off the client)" + +[[bin]] +name = "buzz-avnu-proxy" +path = "src/main.rs" + +[dependencies] +axum = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/buzz-avnu-proxy/src/main.rs b/crates/buzz-avnu-proxy/src/main.rs new file mode 100644 index 00000000000..2f08b244d89 --- /dev/null +++ b/crates/buzz-avnu-proxy/src/main.rs @@ -0,0 +1,203 @@ +//! Server-side proxy for AVNU's SNIP-29 paymaster. +//! +//! The desktop/mobile clients never hold `AVNU_API_KEY`. They call this proxy; +//! the proxy injects `x-paymaster-api-key` from `process`-equivalent env and +//! forwards JSON-RPC to AVNU. +//! +//! # Required environment +//! +//! ```text +//! AVNU_API_KEY Managed AVNU API key (never commit; never ship in the +//! Tauri binary or frontend bundle) +//! ``` +//! +//! # Optional environment +//! +//! ```text +//! AVNU_PAYMASTER_URL Upstream paymaster JSON-RPC endpoint. +//! Default: https://starknet.paymaster.avnu.fi +//! Test: https://sepolia.paymaster.avnu.fi +//! BIND_ADDR Listen address. Default: 0.0.0.0:8788 +//! +//! INDEXER_URL Product market indexer base URL. Required for deploy +//! of listing clients; production value is +//! https://markets.bitcoinmarkets.app +//! (GET {INDEXER_URL}/api/markets, GET {INDEXER_URL}/health). +//! Do NOT default this to http://127.0.0.1:8787 — loopback +//! is listing-proof only. +//! ``` + +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use serde_json::Value; +use std::net::SocketAddr; +use std::sync::Arc; +use tower_http::cors::{Any, CorsLayer}; +use tracing::{error, info, warn}; + +const DEFAULT_UPSTREAM: &str = "https://starknet.paymaster.avnu.fi"; +const DEFAULT_BIND: &str = "0.0.0.0:8788"; +const PRODUCT_INDEXER_URL: &str = "https://markets.bitcoinmarkets.app"; + +#[derive(Clone)] +struct AppState { + client: reqwest::Client, + upstream: String, + api_key: String, +} + +#[derive(Debug, thiserror::Error)] +enum BootError { + #[error("AVNU_API_KEY is required (set it in the environment; never commit the value)")] + MissingApiKey, + #[error("invalid BIND_ADDR {0:?}: {1}")] + BadBind(String, String), + #[error("bind failed: {0}")] + Bind(#[from] std::io::Error), +} + +#[tokio::main] +async fn main() -> Result<(), BootError> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let api_key = std::env::var("AVNU_API_KEY") + .map(|s| s.trim().to_string()) + .ok() + .filter(|s| !s.is_empty()) + .ok_or(BootError::MissingApiKey)?; + + let upstream = std::env::var("AVNU_PAYMASTER_URL") + .unwrap_or_else(|_| DEFAULT_UPSTREAM.to_string()) + .trim_end_matches('/') + .to_string(); + + // Documented for operators; this proxy does not call the indexer, but the + // same deploy unit usually sets INDEXER_URL. Refuse a loopback default by + // never inventing one here — only warn if an operator set loopback. + match std::env::var("INDEXER_URL") { + Ok(url) if url.contains("127.0.0.1") || url.contains("localhost") => { + warn!( + indexer_url = %url, + product = PRODUCT_INDEXER_URL, + "INDEXER_URL points at loopback; production is https://markets.bitcoinmarkets.app" + ); + } + Ok(url) => info!(indexer_url = %url, "INDEXER_URL set"), + Err(_) => info!( + product = PRODUCT_INDEXER_URL, + "INDEXER_URL unset on this process (clients must use https://markets.bitcoinmarkets.app)" + ), + } + + let bind_raw = std::env::var("BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND.to_string()); + let addr: SocketAddr = bind_raw.parse().map_err(|e: std::net::AddrParseError| { + BootError::BadBind(bind_raw.clone(), e.to_string()) + })?; + + let state = Arc::new(AppState { + client: reqwest::Client::new(), + upstream, + api_key, + }); + + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = Router::new() + .route("/health", get(health)) + .route("/", post(proxy_rpc)) + .route("/rpc", post(proxy_rpc)) + .with_state(state) + .layer(cors); + + info!(%addr, "buzz-avnu-proxy listening"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +async fn health() -> impl IntoResponse { + ( + StatusCode::OK, + axum::Json(serde_json::json!({ + "status": "ok", + "service": "buzz-avnu-proxy", + })), + ) +} + +async fn proxy_rpc(State(state): State>, body: Bytes) -> Response { + // Validate JSON so we never forward garbage that could confuse operators' + // logs into looking like a key leak. + if let Err(e) = serde_json::from_slice::(&body) { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "jsonrpc": "2.0", + "error": { "code": -32700, "message": format!("parse error: {e}") }, + "id": null + })), + ) + .into_response(); + } + + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static("application/json"), + ); + headers.insert("accept", HeaderValue::from_static("*/*")); + // Inject the secret server-side. Never echo it back. + match HeaderValue::from_str(&state.api_key) { + Ok(v) => { + headers.insert("x-paymaster-api-key", v); + } + Err(_) => { + error!("AVNU_API_KEY contains characters illegal in an HTTP header"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "jsonrpc": "2.0", + "error": { "code": -32000, "message": "proxy misconfigured" }, + "id": null + })), + ) + .into_response(); + } + } + + let upstream = state.client.post(&state.upstream).headers(headers).body(body); + + match upstream.send().await { + Ok(resp) => { + let status = + StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let bytes = resp.bytes().await.unwrap_or_default(); + (status, [("content-type", "application/json")], bytes).into_response() + } + Err(e) => { + error!(error = %e, "upstream AVNU request failed"); + ( + StatusCode::BAD_GATEWAY, + axum::Json(serde_json::json!({ + "jsonrpc": "2.0", + "error": { "code": -32001, "message": "upstream unavailable" }, + "id": null + })), + ) + .into_response() + } + } +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 5b7cca1ad65..279cd888c67 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -42,6 +42,8 @@ pub mod relay; pub mod sponsorship; /// Address derivation for Nostr-key-controlled Starknet accounts. pub mod starknet_account; +/// Bitcoin Markets wallet fee, retarget halt, and human-only account gate. +pub mod markets; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// Schnorr signature and event ID verification. diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs new file mode 100644 index 00000000000..e3ff177006b --- /dev/null +++ b/crates/buzz-core/src/markets.rs @@ -0,0 +1,218 @@ +//! Bitcoin Markets product helpers: wallet fee, retarget halt, and human-only +//! Starknet account ownership. +//! +//! The UI never surfaces L2 vocabulary. These helpers keep the wallet-side +//! invariants correct regardless of how the frontend is framed. + +use starknet_crypto::Felt; + +/// Wallet fee in basis points charged on each bet (`ceil(amount * bps / 10_000)`). +pub const WALLET_FEE_BPS: u128 = 10; + +/// Fee recipient for the wallet fee transfer (mainnet). +pub const FEE_RECIPIENT: &str = + "0x03df153485c79b693c42563d71abd315635a4819ba3415d07f8421b4ebc839c6"; + +/// strkBTC collateral token (8 decimals). Product copy calls this “BTC”. +pub const COLLATERAL_TOKEN: &str = + "0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135"; + +/// Live Bitcoin difficulty market (LOGNORMAL family, raw difficulty axis). +pub const DIFFICULTY_MARKET: &str = + "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; + +/// Distribution factory (mainnet). +pub const FACTORY: &str = + "0x046b18bbc9b0de137e4f919100ee6b61bf37d345f8099ff7f982b7eaffcab62d"; + +/// Declared `NostrAccount` class hash (mainnet). +pub const NOSTR_ACCOUNT_CLASS_HASH: &str = + "0x0414f62ea1ed35f8c7bd3b794d94efc95e01bccf04e0f47211fc198f7f56f537"; + +/// Protocol floor for a trade, in raw 8-decimal units (~0.000977 BTC). +pub const MIN_TRADE_RAW: u128 = 97_700; + +/// Bitcoin difficulty retarget interval. +pub const RETARGET_INTERVAL: u64 = 2016; + +/// Halt betting this many blocks before the next retarget height. +pub const HALT_BLOCKS_BEFORE_RETARGET: u64 = 24; + +/// Product indexer host. Clients read `INDEXER_URL` (required in deploy) and +/// must never fall back to loopback — localhost is listing-proof only. +pub const PRODUCT_INDEXER_URL: &str = "https://markets.bitcoinmarkets.app"; + +/// Keyring entry name for the human identity nsec. +pub const HUMAN_IDENTITY_KEYRING_NAME: &str = "identity"; + +/// Errors from markets helpers. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum MarketsError { + /// An agent (or otherwise non-human) key was asked to own a Starknet account. + #[error("only the human identity may own a Starknet account; agent keys are excluded")] + AgentKeyNotAllowed, + /// A keyring name was empty or malformed. + #[error("invalid keyring name: {0}")] + InvalidKeyringName(String), +} + +/// Computes the wallet fee for a trade collateral amount. +/// +/// `fee = ceil(token_amount * WALLET_FEE_BPS / 10_000)`, floored at **1 sat** +/// (raw unit) when the product would otherwise be zero. The fee is a separate +/// `transfer` call and must **not** bump `approve` or `supplied_collateral`. +#[must_use] +pub fn wallet_fee_amount(token_amount: u128) -> u128 { + if token_amount == 0 { + return 0; + } + // ceil(a * bps / 10_000) = (a * bps + 9999) / 10_000 + let fee = token_amount + .saturating_mul(WALLET_FEE_BPS) + .saturating_add(9_999) + / 10_000; + fee.max(1) +} + +/// Splits a raw u256 amount into Starknet `(low, high)` felts for calldata. +#[must_use] +pub fn u256_felts(amount: u128) -> (Felt, Felt) { + (Felt::from(amount), Felt::ZERO) +} + +/// Next Bitcoin difficulty retarget height strictly after `current_height`. +/// +/// Retargets occur at multiples of [`RETARGET_INTERVAL`]. When `current_height` +/// is itself a multiple, the *next* retarget is one interval ahead. +#[must_use] +pub fn next_retarget_height(current_height: u64) -> u64 { + let completed = current_height / RETARGET_INTERVAL; + (completed + 1) * RETARGET_INTERVAL +} + +/// Height at which betting must halt (inclusive): 24 blocks before the next +/// retarget. +#[must_use] +pub fn halt_height(current_height: u64) -> u64 { + next_retarget_height(current_height).saturating_sub(HALT_BLOCKS_BEFORE_RETARGET) +} + +/// Whether betting is halted at `current_height`. +/// +/// Halt is height-based, not wall-clock: once the tip reaches +/// `next_retarget - 24`, the wallet refuses new bets until after the retarget. +#[must_use] +pub fn betting_halted(current_height: u64) -> bool { + current_height >= halt_height(current_height) +} + +/// Keyring names that may own a counterfactual Starknet `NostrAccount`. +/// +/// Agent keys live in the same keyring service under `agent:` and must +/// **never** receive a derived account. Only the human `"identity"` entry (or an +/// explicit human pubkey passed by the signing path) is eligible. +pub fn assert_human_keyring_name(name: &str) -> Result<(), MarketsError> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(MarketsError::InvalidKeyringName(name.to_string())); + } + if trimmed == HUMAN_IDENTITY_KEYRING_NAME { + return Ok(()); + } + if trimmed.starts_with("agent:") { + return Err(MarketsError::AgentKeyNotAllowed); + } + // Anything else is not the human identity slot. + Err(MarketsError::AgentKeyNotAllowed) +} + +/// Whether `keyring_name` is the human identity entry. +#[must_use] +pub fn is_human_keyring_name(name: &str) -> bool { + assert_human_keyring_name(name).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fee_is_10_bps_ceil() { + // 10_000 raw * 10 / 10_000 = 10 + assert_eq!(wallet_fee_amount(10_000), 10); + // 1_000 * 10 / 10_000 = 1 exactly + assert_eq!(wallet_fee_amount(1_000), 1); + // 999 * 10 / 10_000 = 0.999 → ceil → 1 (min sat) + assert_eq!(wallet_fee_amount(999), 1); + // 100 * 10 / 10_000 = 0.1 → ceil → 1 + assert_eq!(wallet_fee_amount(100), 1); + // Large amount + assert_eq!(wallet_fee_amount(2_000_000), 2_000); + } + + #[test] + fn fee_min_is_one_sat_when_product_would_be_zero() { + assert_eq!(wallet_fee_amount(1), 1); + assert_eq!(wallet_fee_amount(50), 1); + } + + #[test] + fn fee_zero_amount_is_zero() { + assert_eq!(wallet_fee_amount(0), 0); + } + + #[test] + fn next_retarget_after_genesis_era() { + assert_eq!(next_retarget_height(0), 2016); + assert_eq!(next_retarget_height(1), 2016); + assert_eq!(next_retarget_height(2015), 2016); + assert_eq!(next_retarget_height(2016), 4032); + assert_eq!(next_retarget_height(2017), 4032); + } + + #[test] + fn halt_is_24_blocks_before_retarget() { + // Tip in the first era: halt at 2016 - 24 = 1992 + assert_eq!(halt_height(1000), 1992); + assert!(!betting_halted(1991)); + assert!(betting_halted(1992)); + assert!(betting_halted(2015)); + // After retarget at 2016, next halt is 4032 - 24 = 4008 + assert!(!betting_halted(2016)); + assert_eq!(halt_height(2016), 4008); + assert!(betting_halted(4008)); + } + + #[test] + fn human_identity_may_own_account() { + assert!(is_human_keyring_name("identity")); + assert_eq!(assert_human_keyring_name("identity"), Ok(())); + } + + #[test] + fn agent_keys_do_not_get_starknet_accounts() { + let agent = "agent:8dae5a92916c512029ad1534fcf264e0e2e33ce492acf34588bc6268f7570dd5"; + assert!(!is_human_keyring_name(agent)); + assert_eq!( + assert_human_keyring_name(agent), + Err(MarketsError::AgentKeyNotAllowed) + ); + assert_eq!( + assert_human_keyring_name("agent:anything"), + Err(MarketsError::AgentKeyNotAllowed) + ); + // Random non-identity names are also refused. + assert_eq!( + assert_human_keyring_name("some-other-slot"), + Err(MarketsError::AgentKeyNotAllowed) + ); + } + + #[test] + fn product_indexer_is_not_loopback() { + assert!(PRODUCT_INDEXER_URL.starts_with("https://")); + assert!(!PRODUCT_INDEXER_URL.contains("127.0.0.1")); + assert!(!PRODUCT_INDEXER_URL.contains("localhost")); + assert_eq!(PRODUCT_INDEXER_URL, "https://markets.bitcoinmarkets.app"); + } +} diff --git a/desktop/package.json b/desktop/package.json index 39e93d8a98d..f294bab4e12 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -84,7 +84,14 @@ "upng-js": "^2.1.0", "virtua": "0.49.3", "yaml": "^2.8.3", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@the-situation/artifacts": "^0.15.0", + "@the-situation/collateral": "^0.12.1", + "@the-situation/core": "^0.12.0", + "@the-situation/utils": "^0.13.0", + "@atomiqlabs/sdk": "^8.9.6", + "@atomiqlabs/chain-starknet": "^8.6.0", + "starknet": "^9.2.1" }, "devDependencies": { "@noble/hashes": "^2.0.1", diff --git a/desktop/src-tauri/src/commands/markets.rs b/desktop/src-tauri/src/commands/markets.rs new file mode 100644 index 00000000000..384a3fb9f24 --- /dev/null +++ b/desktop/src-tauri/src/commands/markets.rs @@ -0,0 +1,465 @@ +//! Product-level Bitcoin Markets commands. +//! +//! - [`fund_lightning`] — returns the human wallet address for Atomiq LN funding. +//! No bet path, no trade calls. +//! - [`place_bet`] — signs and submits a prepared Call[] (fee + approve + +//! execute_trade) via the AVNU proxy. No Lightning / Atomiq / invoice. +//! +//! Signing uses [`AppState::signing_keys`] (human nsec only). Agent keyring +//! entries never receive a Starknet account. + +use crate::app_state::AppState; +use buzz_core_pkg::markets::{ + assert_human_keyring_name, betting_halted, wallet_fee_amount, HUMAN_IDENTITY_KEYRING_NAME, + NOSTR_ACCOUNT_CLASS_HASH, PRODUCT_INDEXER_URL, +}; +use buzz_core_pkg::outside_execution::{ + any_caller, OutsideCall, OutsideExecution, +}; +use buzz_core_pkg::starknet_account::{ + account_address_from_hex, constructor_calldata, pubkey_felts, sign_tx_hash, DEPLOY_SALT, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use starknet_core::types::Felt; +use starknet_core::utils::get_selector_from_name; +use tauri::State; + +/// A Starknet call as the frontend prepares it (no secrets). +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreparedCall { + pub contract_address: String, + pub entrypoint: String, + pub calldata: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FundLightningResult { + /// Counterfactual account address (Atomiq destination). + pub address: String, + /// Human hex pubkey that owns the account. + pub pubkey_hex: String, + pub class_hash: String, + /// Constructor calldata `[pk_low, pk_high]` for undeployed deploymentData. + pub constructor_calldata: Vec, + pub salt: String, + /// Documented product indexer (not used by funding itself). + pub indexer_url: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaceBetResult { + pub tx_hash: String, + pub fee_amount: String, + pub account_address: String, +} + +fn felt_hex(v: &str) -> Result { + Felt::from_hex(v.trim()).map_err(|e| format!("invalid felt {v:?}: {e}")) +} + +fn parse_call(call: &PreparedCall) -> Result { + let to = felt_hex(&call.contract_address)?; + let selector = get_selector_from_name(&call.entrypoint) + .map_err(|e| format!("bad entrypoint {}: {e}", call.entrypoint))?; + let mut calldata = Vec::with_capacity(call.calldata.len()); + for c in &call.calldata { + calldata.push(felt_hex(c)?); + } + Ok(OutsideCall { + to, + selector, + calldata, + }) +} + +fn human_keys(state: &AppState) -> Result { + // Gate: only the human identity keyring slot may own a Starknet account. + assert_human_keyring_name(HUMAN_IDENTITY_KEYRING_NAME).map_err(|e| e.to_string())?; + state.signing_keys() +} + +fn avnu_proxy_url() -> String { + std::env::var("AVNU_PROXY_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8788".to_string()) + .trim_end_matches('/') + .to_string() +} + +fn chain_id_short() -> String { + std::env::var("STARKNET_CHAIN_ID").unwrap_or_else(|_| "SN_MAIN".to_string()) +} + +async fn avnu_rpc(method: &str, params: Value) -> Result { + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + let url = format!("{}/rpc", avnu_proxy_url()); + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| format!("AVNU proxy unreachable ({url}): {e}"))?; + let status = resp.status(); + let value: Value = resp + .json() + .await + .map_err(|e| format!("AVNU proxy returned non-JSON: {e}"))?; + if !status.is_success() { + return Err(format!("AVNU proxy HTTP {status}: {value}")); + } + if let Some(err) = value.get("error") { + return Err(format!("AVNU error: {err}")); + } + value + .get("result") + .cloned() + .ok_or_else(|| format!("AVNU response missing result: {value}")) +} + +fn typed_data_to_outside_execution(typed: &Value) -> Result<(OutsideExecution, String), String> { + let message = typed + .get("message") + .ok_or_else(|| "typed_data missing message".to_string())?; + // SNIP-9 v2 field names (AVNU). + let caller = message + .get("Caller") + .or_else(|| message.get("caller")) + .and_then(|v| v.as_str()) + .map(felt_hex) + .transpose()? + .unwrap_or_else(any_caller); + let nonce = message + .get("Nonce") + .or_else(|| message.get("nonce")) + .and_then(|v| v.as_str()) + .ok_or_else(|| "typed_data missing Nonce".to_string()) + .and_then(felt_hex)?; + let execute_after = message + .get("Execute After") + .or_else(|| message.get("execute_after")) + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) + .unwrap_or(0); + let execute_before = message + .get("Execute Before") + .or_else(|| message.get("execute_before")) + .and_then(|v| { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) + .ok_or_else(|| "typed_data missing Execute Before".to_string())?; + + let calls_val = message + .get("Calls") + .or_else(|| message.get("calls")) + .and_then(|v| v.as_array()) + .ok_or_else(|| "typed_data missing Calls".to_string())?; + + let mut calls = Vec::with_capacity(calls_val.len()); + for c in calls_val { + let to = c + .get("To") + .or_else(|| c.get("to")) + .and_then(|v| v.as_str()) + .ok_or_else(|| "call missing To".to_string()) + .and_then(felt_hex)?; + let selector = c + .get("Selector") + .or_else(|| c.get("selector")) + .and_then(|v| v.as_str()) + .ok_or_else(|| "call missing Selector".to_string()) + .and_then(felt_hex)?; + let calldata = c + .get("Calldata") + .or_else(|| c.get("calldata")) + .and_then(|v| v.as_array()) + .ok_or_else(|| "call missing Calldata".to_string())? + .iter() + .map(|x| { + x.as_str() + .ok_or_else(|| "calldata entry not a string".to_string()) + .and_then(felt_hex) + }) + .collect::, _>>()?; + calls.push(OutsideCall { + to, + selector, + calldata, + }); + } + + let domain = typed + .get("domain") + .ok_or_else(|| "typed_data missing domain".to_string())?; + let chain_id = domain + .get("chainId") + .or_else(|| domain.get("chain_id")) + .and_then(|v| v.as_str()) + .unwrap_or("SN_MAIN") + .to_string(); + // AVNU may return hex chain id; map known values. + let chain_short = if chain_id == "0x534e5f4d41494e" || chain_id == "SN_MAIN" { + "SN_MAIN".to_string() + } else if chain_id == "0x534e5f5345504f4c4941" || chain_id == "SN_SEPOLIA" { + "SN_SEPOLIA".to_string() + } else { + chain_id_short() + }; + + Ok(( + OutsideExecution { + caller, + nonce, + execute_after, + execute_before, + calls, + }, + chain_short, + )) +} + +/// Fund-screen command: derive the human counterfactual address for Atomiq. +/// +/// Does **not** create an LN invoice, swap, or bet. The frontend uses Atomiq +/// FROM_BTCLN_AUTO into this address on the Fund screen only. +#[tauri::command] +pub async fn fund_lightning( + state: State<'_, AppState>, + amount_sats: u64, +) -> Result { + if !(100..=2_000_000).contains(&amount_sats) { + return Err("Amount must be between 100 and 2_000_000 sats".into()); + } + let keys = human_keys(&state)?; + let pubkey_hex = keys.public_key().to_hex(); + let address = account_address_from_hex(NOSTR_ACCOUNT_CLASS_HASH, &pubkey_hex) + .map_err(|e| e.to_string())?; + let ctor = constructor_calldata(&pubkey_hex).map_err(|e| e.to_string())?; + Ok(FundLightningResult { + address: address.to_fixed_hex_string(), + pubkey_hex, + class_hash: NOSTR_ACCOUNT_CLASS_HASH.to_string(), + constructor_calldata: ctor.iter().map(|f| f.to_fixed_hex_string()).collect(), + salt: DEPLOY_SALT.to_fixed_hex_string(), + indexer_url: std::env::var("INDEXER_URL") + .unwrap_or_else(|_| PRODUCT_INDEXER_URL.to_string()), + }) +} + +/// Bet-screen command: sign + submit prepared calls via AVNU (no Lightning). +#[tauri::command] +pub async fn place_bet( + state: State<'_, AppState>, + calls: Vec, + bitcoin_height: u64, + token_amount: String, +) -> Result { + if betting_halted(bitcoin_height) { + return Err( + "Betting is paused until after the next Bitcoin difficulty retarget".into(), + ); + } + if calls.is_empty() { + return Err("place_bet requires at least one call".into()); + } + // Refuse anything that looks like a Lightning/Atomiq entrypoint mixed in. + for c in &calls { + let ep = c.entrypoint.to_ascii_lowercase(); + if ep.contains("lightning") || ep.contains("atomiq") || ep.contains("invoice") { + return Err("place_bet is Starknet-only; Lightning belongs on Fund".into()); + } + } + + let token_amount: u128 = token_amount + .parse() + .map_err(|_| "invalid tokenAmount".to_string())?; + let fee_amount = wallet_fee_amount(token_amount); + + let keys = human_keys(&state)?; + let pubkey_hex = keys.public_key().to_hex(); + let account = account_address_from_hex(NOSTR_ACCOUNT_CLASS_HASH, &pubkey_hex) + .map_err(|e| e.to_string())?; + let (pk_low, pk_high) = pubkey_felts(&pubkey_hex).map_err(|e| e.to_string())?; + + let rpc_calls: Vec = calls + .iter() + .map(|c| { + // Touch parse early so bad calldata fails before paying the proxy. + let _ = parse_call(c)?; + Ok(json!({ + "contractAddress": c.contract_address, + "entrypoint": c.entrypoint, + "calldata": c.calldata, + })) + }) + .collect::>()?; + + let deployment = json!({ + "address": account.to_fixed_hex_string(), + "class_hash": NOSTR_ACCOUNT_CLASS_HASH, + "salt": DEPLOY_SALT.to_fixed_hex_string(), + "calldata": [ + pk_low.to_fixed_hex_string(), + pk_high.to_fixed_hex_string() + ], + "version": 1 + }); + + // Prefer deploy_and_invoke when undeployed; AVNU accepts this class on mainnet. + let build_params = json!({ + "transaction": { + "type": "deploy_and_invoke", + "deployment": deployment, + "invoke": { + "userAddress": account.to_fixed_hex_string(), + "calls": rpc_calls + } + }, + "parameters": { + "version": "0x1", + "feeMode": { "mode": "sponsored" } + } + }); + + let built = match avnu_rpc("paymaster_buildTransaction", build_params).await { + Ok(v) => v, + Err(_) => { + // Fallback: account may already be deployed. + let invoke_only = json!({ + "transaction": { + "type": "invoke", + "invoke": { + "userAddress": account.to_fixed_hex_string(), + "calls": rpc_calls + } + }, + "parameters": { + "version": "0x1", + "feeMode": { "mode": "sponsored" } + } + }); + avnu_rpc("paymaster_buildTransaction", invoke_only).await? + } + }; + + let typed = built + .get("typed_data") + .or_else(|| built.get("typedData")) + .ok_or_else(|| format!("buildTransaction missing typed_data: {built}"))?; + + let (execution, chain_short) = typed_data_to_outside_execution(typed)?; + let msg_hash = execution + .message_hash(account, &chain_short) + .map_err(|e| e.to_string())?; + + let secret = **keys.secret_key(); + let signature = sign_tx_hash(&secret, msg_hash); + let sig_hex: Vec = signature.iter().map(|f| f.to_fixed_hex_string()).collect(); + + let tx_type = built + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("invoke"); + + let execute_params = if tx_type == "deploy_and_invoke" { + json!({ + "transaction": { + "type": "deploy_and_invoke", + "deployment": { + "address": account.to_fixed_hex_string(), + "class_hash": NOSTR_ACCOUNT_CLASS_HASH, + "salt": DEPLOY_SALT.to_fixed_hex_string(), + "calldata": [ + pk_low.to_fixed_hex_string(), + pk_high.to_fixed_hex_string() + ], + "version": 1 + }, + "invoke": { + "userAddress": account.to_fixed_hex_string(), + "typedData": typed, + "signature": sig_hex + } + }, + "parameters": { + "version": "0x1", + "feeMode": { "mode": "sponsored" } + } + }) + } else { + json!({ + "transaction": { + "type": "invoke", + "invoke": { + "userAddress": account.to_fixed_hex_string(), + "typedData": typed, + "signature": sig_hex + } + }, + "parameters": { + "version": "0x1", + "feeMode": { "mode": "sponsored" } + } + }) + }; + + let executed = avnu_rpc("paymaster_executeTransaction", execute_params).await?; + let tx_hash = executed + .get("transaction_hash") + .or_else(|| executed.get("transactionHash")) + .and_then(|v| v.as_str()) + .ok_or_else(|| format!("executeTransaction missing hash: {executed}"))? + .to_string(); + + Ok(PlaceBetResult { + tx_hash, + fee_amount: fee_amount.to_string(), + account_address: account.to_fixed_hex_string(), + }) +} + +/// Derive the human wallet address (read-only). +#[tauri::command] +pub async fn bitcoin_wallet_address( + state: State<'_, AppState>, +) -> Result { + fund_lightning(state, 100).await +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core_pkg::markets::{is_human_keyring_name, MarketsError}; + + #[test] + fn agent_keyring_names_are_rejected_before_signing() { + assert!(is_human_keyring_name(HUMAN_IDENTITY_KEYRING_NAME)); + assert_eq!( + assert_human_keyring_name("agent:abc"), + Err(MarketsError::AgentKeyNotAllowed) + ); + } + + #[test] + fn prepared_call_serde_roundtrip() { + let c = PreparedCall { + contract_address: "0x1".into(), + entrypoint: "execute_trade".into(), + calldata: vec!["0x2".into()], + }; + let v = serde_json::to_value(&c).unwrap(); + assert_eq!(v["contractAddress"], "0x1"); + assert_eq!(v["entrypoint"], "execute_trade"); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 761bee9cd32..6187b18f686 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -26,7 +26,8 @@ mod join_policy; mod legacy_storage; mod link_preview; mod managed_agent_definition; -pub(crate) mod media; +pub(crate) mod markets; +mod media; mod media_animated; mod media_download; mod media_gif; @@ -91,6 +92,7 @@ pub use identity_archive::*; pub use join_policy::*; pub use legacy_storage::*; pub use link_preview::*; +pub use markets::{bitcoin_wallet_address, fund_lightning, place_bet}; pub use media::*; pub use media_download::*; pub use media_raw::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 9da761b8b78..fcdc5f65262 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -631,6 +631,9 @@ pub fn run() { transfer_builderlab_community, title_bar_double_click, get_identity, + fund_lightning, + place_bet, + bitcoin_wallet_address, get_nsec, generate_backup_passphrase, create_ncryptsec_backup, diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e82..95110197252 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -9,6 +9,7 @@ export type AppView = | "agents" | "workflows" | "pulse" + | "markets" | "projects"; const WINDOW_DRAG_HANDLE_HEIGHT = 44; @@ -188,6 +189,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/markets") { + return { + selectedChannelId: null, + selectedView: "markets", + }; + } + return { selectedChannelId: null, selectedView: "home", diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b720..33bcfa6eb69 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -142,6 +142,7 @@ export function AppShell() { goNewMessage, goProjects, goPulse, + goMarkets, goSettings, goWorkflows, closeSettings, @@ -857,6 +858,7 @@ export function AppShell() { ]} onSelectHome={() => void goHome()} onSelectProjects={() => void goProjects()} + onSelectMarkets={() => void goMarkets()} onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2203aa03a6a..d5006f7727c 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -81,6 +81,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goMarkets = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/markets", + }, + behavior, + ), + [commitNavigation], + ); + const goProfile = React.useCallback( (pubkey: string, behavior?: NavigationBehavior) => commitNavigation( @@ -336,6 +347,7 @@ export function useAppNavigation() { goProject, goProjects, goPulse, + goMarkets, goProfile, goSettings, goWorkflow, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6d..b3cd655468a 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -9,6 +9,7 @@ import { Route as workflowsRouteImport } from "./routes/workflows"; import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; import { Route as pulseRouteImport } from "./routes/pulse"; +import { Route as marketsRouteImport } from "./routes/markets"; import { Route as projectsRouteImport } from "./routes/projects"; import { Route as agentsRouteImport } from "./routes/agents"; import { Route as indexRouteImport } from "./routes/index"; @@ -38,6 +39,11 @@ const pulseRoute = pulseRouteImport.update({ path: "/pulse", getParentRoute: () => rootRouteImport, } as any); +const marketsRoute = marketsRouteImport.update({ + id: "/markets", + path: "/markets", + getParentRoute: () => rootRouteImport, +} as any); const projectsRoute = projectsRouteImport.update({ id: "/projects", path: "/projects", @@ -85,6 +91,7 @@ export interface FileRoutesByFullPath { "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; + "/markets": typeof marketsRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; @@ -99,6 +106,7 @@ export interface FileRoutesByTo { "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; + "/markets": typeof marketsRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; @@ -114,6 +122,7 @@ export interface FileRoutesById { "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; + "/markets": typeof marketsRoute; "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; @@ -130,6 +139,7 @@ export interface FileRouteTypes { | "/agents" | "/projects" | "/pulse" + | "/markets" | "/reminders" | "/settings" | "/workflows" @@ -144,6 +154,7 @@ export interface FileRouteTypes { | "/agents" | "/projects" | "/pulse" + | "/markets" | "/reminders" | "/settings" | "/workflows" @@ -158,6 +169,7 @@ export interface FileRouteTypes { | "/agents" | "/projects" | "/pulse" + | "/markets" | "/reminders" | "/settings" | "/workflows" @@ -173,6 +185,7 @@ export interface RootRouteChildren { agentsRoute: typeof agentsRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; + marketsRoute: typeof marketsRoute; remindersRoute: typeof remindersRoute; settingsRoute: typeof settingsRoute; workflowsRoute: typeof workflowsRoute; @@ -213,6 +226,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof pulseRouteImport; parentRoute: typeof rootRouteImport; }; + "/markets": { + id: "/markets"; + path: "/markets"; + fullPath: "/markets"; + preLoaderRoute: typeof marketsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/projects": { id: "/projects"; path: "/projects"; @@ -277,6 +297,7 @@ const rootRouteChildren: RootRouteChildren = { agentsRoute: agentsRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, + marketsRoute: marketsRoute, remindersRoute: remindersRoute, settingsRoute: settingsRoute, workflowsRoute: workflowsRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11a..114139f2db1 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -4,6 +4,7 @@ export const routes = rootRoute("root.tsx", [ index("index.tsx"), route("/agents", "agents.tsx"), route("/pulse", "pulse.tsx"), + route("/markets", "markets.tsx"), route("/reminders", "reminders.tsx"), route("/settings", "settings.tsx"), route("/workflows", "workflows.tsx"), diff --git a/desktop/src/app/routes/markets.tsx b/desktop/src/app/routes/markets.tsx new file mode 100644 index 00000000000..aeb9816a9c4 --- /dev/null +++ b/desktop/src/app/routes/markets.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const MarketsScreen = React.lazy(async () => { + const module = await import("@/features/markets/ui/MarketsScreen"); + return { default: module.MarketsScreen }; +}); + +export const Route = createFileRoute("/markets")({ + component: MarketsRouteComponent, +}); + +function MarketsRouteComponent() { + return ( + }> + + + ); +} diff --git a/desktop/src/features/markets/lib/constants.ts b/desktop/src/features/markets/lib/constants.ts new file mode 100644 index 00000000000..74f1baf5945 --- /dev/null +++ b/desktop/src/features/markets/lib/constants.ts @@ -0,0 +1,42 @@ +/** + * Bitcoin Markets product constants. + * + * UI copy must never surface L2 vocabulary (Starknet, STRK, felts, strkBTC, + * deploy, paymaster). Collateral is always labeled “BTC”. + */ + +/** Product indexer host. Override with `VITE_INDEXER_URL` / `INDEXER_URL`. */ +export const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; + +/** Live LOGNORMAL difficulty market (raw difficulty axis). */ +export const DIFFICULTY_MARKET = + "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; + +export const MARKET_TITLE = "Bitcoin difficulty after next retarget"; + +export const FACTORY = + "0x046b18bbc9b0de137e4f919100ee6b61bf37d345f8099ff7f982b7eaffcab62d"; + +/** Collateral token (8 decimals). Product label: “BTC”. */ +export const COLLATERAL_TOKEN = + "0x0787150e306e6eae6e3f79dea881770e8bbff2c1b8eb490f969669ee945b3135"; + +export const FEE_RECIPIENT = + "0x03df153485c79b693c42563d71abd315635a4819ba3415d07f8421b4ebc839c6"; + +export const NOSTR_ACCOUNT_CLASS_HASH = + "0x0414f62ea1ed35f8c7bd3b794d94efc95e01bccf04e0f47211fc198f7f56f537"; + +export const WALLET_FEE_BPS = 10; + +/** Protocol floor ~0.000977 BTC in 8-decimal raw units. */ +export const MIN_TRADE_RAW = 97_700n; + +export const RETARGET_INTERVAL = 2016; +export const HALT_BLOCKS_BEFORE_RETARGET = 24; + +/** Lightning fund bounds (sats). */ +export const LN_MIN_SATS = 100n; +export const LN_MAX_SATS = 2_000_000n; + +export const COLLATERAL_DECIMALS = 8; diff --git a/desktop/src/features/markets/lib/fee.ts b/desktop/src/features/markets/lib/fee.ts new file mode 100644 index 00000000000..8c082aa7d7e --- /dev/null +++ b/desktop/src/features/markets/lib/fee.ts @@ -0,0 +1,22 @@ +import { WALLET_FEE_BPS } from "./constants"; + +/** + * Wallet fee: ceil(tokenAmount * 10 / 10_000), minimum 1 sat when amount > 0. + * Do not bump approve or supplied_collateral by this amount. + */ +export function walletFeeAmount(tokenAmount: bigint): bigint { + if (tokenAmount <= 0n) { + return 0n; + } + const fee = + (tokenAmount * BigInt(WALLET_FEE_BPS) + 9_999n) / 10_000n; + return fee < 1n ? 1n : fee; +} + +/** Split a u128 amount into Starknet u256 low/high hex limbs. */ +export function u256Calldata(amount: bigint): [string, string] { + const mask = (1n << 128n) - 1n; + const low = amount & mask; + const high = amount >> 128n; + return [`0x${low.toString(16)}`, `0x${high.toString(16)}`]; +} diff --git a/desktop/src/features/markets/lib/feeCall.ts b/desktop/src/features/markets/lib/feeCall.ts new file mode 100644 index 00000000000..9f01832a861 --- /dev/null +++ b/desktop/src/features/markets/lib/feeCall.ts @@ -0,0 +1,19 @@ +/** + * Build the wallet fee transfer call (prepended before trade.calls). + * Does NOT bump approve or supplied_collateral. + */ + +import type { Call } from "starknet"; + +import { COLLATERAL_TOKEN, FEE_RECIPIENT } from "./constants"; +import { u256Calldata, walletFeeAmount } from "./fee"; + +export function buildFeeCall(tokenAmount: bigint): Call { + const fee = walletFeeAmount(tokenAmount); + const [feeLow, feeHigh] = u256Calldata(fee); + return { + contractAddress: COLLATERAL_TOKEN, + entrypoint: "transfer", + calldata: [FEE_RECIPIENT, feeLow, feeHigh], + }; +} diff --git a/desktop/src/features/markets/lib/fundLightning.ts b/desktop/src/features/markets/lib/fundLightning.ts new file mode 100644 index 00000000000..080dc5d2580 --- /dev/null +++ b/desktop/src/features/markets/lib/fundLightning.ts @@ -0,0 +1,111 @@ +/** + * Lightning funding via Atomiq (FROM_BTCLN_AUTO → strkBTC). + * + * This module is Fund-screen only. Betting never imports it — place_bet is + * 100% hidden Starknet calls with no LN invoice, zap, or Atomiq swap. + */ + +import { + BitcoinNetwork, + SwapAmountType, + SwapperFactory, +} from "@atomiqlabs/sdk"; +import { StarknetInitializer } from "@atomiqlabs/chain-starknet"; + +import { LN_MAX_SATS, LN_MIN_SATS } from "./constants"; + +const Factory = new SwapperFactory([StarknetInitializer] as const); +const Tokens = Factory.Tokens; + +export type FundLightningQuote = { + invoice: string; + hyperlink: string; + /** Human-facing input amount description. */ + inputSats: bigint; + /** Output amount in 8dp raw units (product label: BTC). */ + outputRaw: bigint; + expiryMs: number; + /** Underlying swap handle for execute/wait. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + swap: any; +}; + +export type FundLightningOptions = { + amountSats: bigint; + /** Counterfactual NostrAccount address (destination). */ + destinationAddress: string; + starknetRpcUrl: string; + /** + * Optional gas drop in STRK wei. Fallback only — AVNU sponsors gas for bets. + * Omit in the happy path. + */ + gasAmount?: bigint; +}; + +function assertSatsInRange(amountSats: bigint) { + if (amountSats < LN_MIN_SATS || amountSats > LN_MAX_SATS) { + throw new Error( + `Amount must be between ${LN_MIN_SATS} and ${LN_MAX_SATS} sats`, + ); + } +} + +/** + * Create a live Lightning → BTC (strkBTC) quote into the hidden wallet. + * Does not place a bet. + */ +export async function createFundLightningQuote( + options: FundLightningOptions, +): Promise { + assertSatsInRange(options.amountSats); + + const swapper = Factory.newSwapper({ + chains: { + STARKNET: { + rpcUrl: options.starknetRpcUrl, + }, + }, + bitcoinNetwork: BitcoinNetwork.MAINNET, + }); + await swapper.init(); + + const swapOpts = + options.gasAmount !== undefined ? { gasAmount: options.gasAmount } : {}; + + const swap = await swapper.swap( + Tokens.BITCOIN.BTCLN, + Tokens.STARKNET.strkBTC, + options.amountSats, + SwapAmountType.EXACT_IN, + undefined, + options.destinationAddress, + swapOpts, + ); + + return { + invoice: swap.getAddress(), + hyperlink: swap.getHyperlink(), + inputSats: options.amountSats, + outputRaw: BigInt(swap.getOutput().rawAmount), + expiryMs: swap.getQuoteExpiry(), + swap, + }; +} + +/** + * Wait for the Lightning payment and automatic settlement. + * Fund path only — never call from place_bet. + */ +export async function waitFundLightningSettlement( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + swap: any, +): Promise<{ automatic: boolean; claimTxId?: string }> { + const automatic = await swap.execute( + { + // External wallet pays the invoice shown in the Fund UI. + payInvoice: async () => "", + }, + {}, + ); + return { automatic: Boolean(automatic) }; +} diff --git a/desktop/src/features/markets/lib/halt.ts b/desktop/src/features/markets/lib/halt.ts new file mode 100644 index 00000000000..430b6acffb0 --- /dev/null +++ b/desktop/src/features/markets/lib/halt.ts @@ -0,0 +1,20 @@ +import { + HALT_BLOCKS_BEFORE_RETARGET, + RETARGET_INTERVAL, +} from "./constants"; + +/** Next retarget height strictly after `currentHeight`. */ +export function nextRetargetHeight(currentHeight: number): number { + const completed = Math.floor(currentHeight / RETARGET_INTERVAL); + return (completed + 1) * RETARGET_INTERVAL; +} + +/** Inclusive halt height: 24 blocks before the next retarget. */ +export function haltHeight(currentHeight: number): number { + return nextRetargetHeight(currentHeight) - HALT_BLOCKS_BEFORE_RETARGET; +} + +/** Height-based betting halt (not wall-clock). */ +export function bettingHalted(currentHeight: number): boolean { + return currentHeight >= haltHeight(currentHeight); +} diff --git a/desktop/src/features/markets/lib/indexer.ts b/desktop/src/features/markets/lib/indexer.ts new file mode 100644 index 00000000000..81766da4792 --- /dev/null +++ b/desktop/src/features/markets/lib/indexer.ts @@ -0,0 +1,64 @@ +import { PRODUCT_INDEXER_URL } from "./constants"; + +/** + * Resolve the markets indexer base URL. + * + * Production host is `https://markets.bitcoinmarkets.app`. Loopback + * (`127.0.0.1` / `localhost`) is listing-proof only and must never ship as the + * client default. + */ +export function resolveIndexerUrl( + env: Record = import.meta.env as Record< + string, + string | undefined + >, +): string { + const raw = + env.VITE_INDEXER_URL?.trim() || + env.INDEXER_URL?.trim() || + PRODUCT_INDEXER_URL; + const base = raw.replace(/\/$/, ""); + if (/127\.0\.0\.1|localhost/i.test(base)) { + throw new Error( + "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app", + ); + } + return base; +} + +export type IndexerMarket = { + address: string; + title: string; + marketType?: string; + state?: { + mean: number | null; + sigma: number | null; + variance: number | null; + k: number | null; + effectiveK: number | null; + isInitialized: boolean; + isPaused: boolean; + isSettled: boolean; + totalBacking: number | null; + } | null; +}; + +export async function fetchIndexerHealth( + baseUrl = resolveIndexerUrl(), +): Promise<{ status: string }> { + const res = await fetch(`${baseUrl}/health`); + if (!res.ok) { + throw new Error(`Indexer health failed: HTTP ${res.status}`); + } + return (await res.json()) as { status: string }; +} + +export async function fetchIndexerMarkets( + baseUrl = resolveIndexerUrl(), +): Promise { + const res = await fetch(`${baseUrl}/api/markets`); + if (!res.ok) { + throw new Error(`Indexer markets failed: HTTP ${res.status}`); + } + return (await res.json()) as IndexerMarket[]; +} diff --git a/desktop/src/features/markets/lib/lognormalHints.ts b/desktop/src/features/markets/lib/lognormalHints.ts new file mode 100644 index 00000000000..1cd747decbb --- /dev/null +++ b/desktop/src/features/markets/lib/lognormalHints.ts @@ -0,0 +1,60 @@ +import { computeL2NormDenomHint } from "@the-situation/utils"; +import { SQ128x128 } from "@the-situation/core"; + +export type Sq128Raw = { + limb0: bigint; + limb1: bigint; + limb2: bigint; + limb3: bigint; + neg: boolean; +}; + +export type LognormalSqrtHints = { + l2_norm_denom: Sq128Raw; + backing_denom: Sq128Raw; +}; + +/** + * Both denoms = isqrt(2 * sigma * sqrt_pi), identical limbs. + * Do NOT use normal computeHints (two different denoms → revert). + */ +export function computeLognormalHints( + sigma: InstanceType, +): LognormalSqrtHints | null { + const denom = computeL2NormDenomHint(sigma); + if (!denom) { + return null; + } + const raw = denom.toRaw(); + return { + l2_norm_denom: raw, + backing_denom: { ...raw }, + }; +} + +export function computeLognormalHintsFromNumber( + sigma: number, +): LognormalSqrtHints { + const sigmaSq = SQ128x128.fromNumber(sigma); + if (!sigmaSq) { + throw new Error(`Failed to convert sigma=${sigma} to SQ128x128`); + } + const hints = computeLognormalHints(sigmaSq); + if (!hints) { + throw new Error(`Failed to compute lognormal hints for sigma=${sigma}`); + } + return hints; +} + +/** Test helper: denoms must be limb-identical. */ +export function hintsDenomsMatch(hints: LognormalSqrtHints): boolean { + const a = hints.l2_norm_denom; + const b = hints.backing_denom; + return ( + a.limb0 === b.limb0 && + a.limb1 === b.limb1 && + a.limb2 === b.limb2 && + a.limb3 === b.limb3 && + a.neg === b.neg + ); +} diff --git a/desktop/src/features/markets/lib/markets.test.mjs b/desktop/src/features/markets/lib/markets.test.mjs new file mode 100644 index 00000000000..e79ce1cc178 --- /dev/null +++ b/desktop/src/features/markets/lib/markets.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +const WALLET_FEE_BPS = 10n; +const RETARGET_INTERVAL = 2016; +const HALT_BLOCKS_BEFORE_RETARGET = 24; +const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; + +function walletFeeAmount(tokenAmount) { + if (tokenAmount <= 0n) return 0n; + const fee = (tokenAmount * WALLET_FEE_BPS + 9_999n) / 10_000n; + return fee < 1n ? 1n : fee; +} + +function nextRetargetHeight(currentHeight) { + return (Math.floor(currentHeight / RETARGET_INTERVAL) + 1) * RETARGET_INTERVAL; +} + +function haltHeight(currentHeight) { + return nextRetargetHeight(currentHeight) - HALT_BLOCKS_BEFORE_RETARGET; +} + +function bettingHalted(currentHeight) { + return currentHeight >= haltHeight(currentHeight); +} + +function resolveIndexerUrl(env = {}) { + const raw = + (env.VITE_INDEXER_URL || "").trim() || + (env.INDEXER_URL || "").trim() || + PRODUCT_INDEXER_URL; + const base = raw.replace(/\/$/, ""); + if (/127\.0\.0\.1|localhost/i.test(base)) { + throw new Error( + "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app", + ); + } + return base; +} + +describe("walletFeeAmount", () => { + it("charges 10 bps with ceil", () => { + assert.equal(walletFeeAmount(10_000n), 10n); + assert.equal(walletFeeAmount(1_000n), 1n); + assert.equal(walletFeeAmount(2_000_000n), 2_000n); + }); + + it("floors at 1 sat when the product would be 0", () => { + assert.equal(walletFeeAmount(1n), 1n); + assert.equal(walletFeeAmount(50n), 1n); + assert.equal(walletFeeAmount(999n), 1n); + }); + + it("returns 0 for zero amount", () => { + assert.equal(walletFeeAmount(0n), 0n); + }); +}); + +describe("betting halt at height", () => { + it("retargets every 2016 blocks", () => { + assert.equal(nextRetargetHeight(0), 2016); + assert.equal(nextRetargetHeight(2015), 2016); + assert.equal(nextRetargetHeight(2016), 4032); + }); + + it("halts 24 blocks before the next retarget", () => { + assert.equal(haltHeight(1000), 1992); + assert.equal(bettingHalted(1991), false); + assert.equal(bettingHalted(1992), true); + assert.equal(bettingHalted(2015), true); + assert.equal(bettingHalted(2016), false); + assert.equal(haltHeight(2016), 4008); + assert.equal(bettingHalted(4008), true); + }); +}); + +describe("INDEXER_URL", () => { + it("uses the product host, never loopback", () => { + assert.equal(PRODUCT_INDEXER_URL, "https://markets.bitcoinmarkets.app"); + assert.equal(resolveIndexerUrl({}), PRODUCT_INDEXER_URL); + assert.equal( + resolveIndexerUrl({ + VITE_INDEXER_URL: "https://markets.bitcoinmarkets.app/", + }), + "https://markets.bitcoinmarkets.app", + ); + assert.throws( + () => resolveIndexerUrl({ INDEXER_URL: "http://127.0.0.1:8787" }), + /must not be loopback/, + ); + }); +}); diff --git a/desktop/src/features/markets/lib/placeBet.ts b/desktop/src/features/markets/lib/placeBet.ts new file mode 100644 index 00000000000..a6e249f4390 --- /dev/null +++ b/desktop/src/features/markets/lib/placeBet.ts @@ -0,0 +1,65 @@ +/** + * Bet path: prepare lognormal trade + prepend feeCall, then hand Call[] to + * Tauri `place_bet`. No Lightning / Atomiq / invoice on this path. + */ + +import type { Call } from "starknet"; + +import { invokeTauri } from "@/shared/api/tauri"; + +import { buildFeeCall } from "./feeCall"; +import { + prepareLognormalTrade, + type MarketSnapshot, + type PreparedLognormalTrade, +} from "./prepareLognormalTrade"; + +export type PlaceBetParams = { + rawDifficulty: number; + market: MarketSnapshot; + bitcoinHeight: number; + targetVariance?: number; + bufferPercent?: number; +}; + +export type PlaceBetResult = { + txHash: string; + summary: string; + tokenAmount: string; + feeAmount: string; +}; + +export function buildBetCalls(prepared: PreparedLognormalTrade): Call[] { + const feeCall = buildFeeCall(prepared.tokenAmount); + // fee first, then approve + execute_trade. Never mix LN here. + return [feeCall, ...prepared.calls]; +} + +/** + * Prepare and submit a curve bet. Signing stays in Rust via `place_bet`. + */ +export async function placeBet(params: PlaceBetParams): Promise { + const prepared = prepareLognormalTrade({ + rawDifficulty: params.rawDifficulty, + market: params.market, + targetVariance: params.targetVariance, + bufferPercent: params.bufferPercent, + }); + const calls = buildBetCalls(prepared); + + const result = await invokeTauri<{ + txHash: string; + feeAmount: string; + }>("place_bet", { + calls, + bitcoinHeight: params.bitcoinHeight, + tokenAmount: prepared.tokenAmount.toString(), + }); + + return { + txHash: result.txHash, + summary: prepared.summary, + tokenAmount: prepared.tokenAmount.toString(), + feeAmount: result.feeAmount, + }; +} diff --git a/desktop/src/features/markets/lib/prepareLognormalTrade.ts b/desktop/src/features/markets/lib/prepareLognormalTrade.ts new file mode 100644 index 00000000000..943e9591a68 --- /dev/null +++ b/desktop/src/features/markets/lib/prepareLognormalTrade.ts @@ -0,0 +1,217 @@ +/** + * Lognormal prepare path for the Bitcoin difficulty market. + * + * There is NO prepareLognormalTrade in @the-situation/sdk — only the + * normal-family prepareTrade. We therefore: + * 1. Treat the UI axis as raw difficulty D + * 2. Plan with targetMean = ln(D) in log-space + * 3. Build hints where BOTH denoms = isqrt(2*σ*√π) (same limbs) + * 4. Encode execute_trade against LOGNORMAL_AMM_ABI (candidate.mu, not mean) + * 5. Return calls = [approve(+5%), trade] — caller prepends feeCall + * + * Do NOT call SDK executeTrade(). Do NOT bump approve/supplied_collateral for + * the wallet fee. + */ + +import { LOGNORMAL_AMM_ABI } from "@the-situation/artifacts"; +import { + LognormalDistribution, + SQ128x128, +} from "@the-situation/core"; +import { findLognormalMinimum } from "@the-situation/collateral"; +import { buildApproveCall, toHexAddress } from "@the-situation/utils"; +import { CallData, type Call } from "starknet"; + +import { + COLLATERAL_DECIMALS, + COLLATERAL_TOKEN, + DIFFICULTY_MARKET, + MIN_TRADE_RAW, +} from "./constants"; +import { computeLognormalHints } from "./lognormalHints"; + +export type MarketSnapshot = { + /** Log-space μ (indexer `state.mean` for lognormal). */ + mu: number; + variance: number; + sigma: number; + /** Effective k used for lambda scaling. */ + effectiveK: number; +}; + +export type PrepareLognormalTradeOptions = { + /** Raw Bitcoin difficulty D from the UI axis (NOT ln). */ + rawDifficulty: number; + /** Optional target variance in log-space; defaults to current. */ + targetVariance?: number; + /** Buffer percent on collateral (default 1%). */ + bufferPercent?: number; + /** Current market snapshot (log-space). */ + market: MarketSnapshot; + marketAddress?: string; +}; + +export type PreparedLognormalTrade = { + /** ln(D) used as candidate μ. */ + targetMu: number; + targetVariance: number; + targetSigma: number; + xStar: number; + /** Buffered collateral in human token units. */ + collateral: number; + /** Raw 8dp token amount for approve / fee math. */ + tokenAmount: bigint; + /** [approveCall, tradeCall] — prepend feeCall before execute. */ + calls: Call[]; + summary: string; +}; + +function lognormalL2Norm(mu: number, variance: number): number { + const sigma = Math.sqrt(Math.max(0, variance)); + if (sigma <= 0 || !Number.isFinite(sigma)) { + return 0; + } + const denom = Math.sqrt(2 * sigma * Math.sqrt(Math.PI)); + const scale = Math.exp(variance / 8 - mu / 2); + return scale / denom; +} + +function lognormalLambda(mu: number, variance: number, k: number): number { + const n = lognormalL2Norm(mu, variance); + if (n <= 0 || !Number.isFinite(n)) { + return 0; + } + return k / n; +} + +function toTokenAmountUp(amount: number, decimals: number): bigint { + const scale = 10 ** decimals; + return BigInt(Math.ceil(amount * scale - Number.EPSILON)); +} + +function toAbiSq128(raw: { + limb0: bigint; + limb1: bigint; + limb2: bigint; + limb3: bigint; + neg: boolean; +}) { + return { + limb0: raw.limb0, + limb1: raw.limb1, + limb2: raw.limb2, + limb3: raw.limb3, + neg: raw.neg, + }; +} + +/** + * Prepare a lognormal curve bet. `rawDifficulty` is the UI axis value D; + * internally the candidate mean is ln(D). + */ +export function prepareLognormalTrade( + options: PrepareLognormalTradeOptions, +): PreparedLognormalTrade { + const { + rawDifficulty, + market, + bufferPercent = 1, + marketAddress = DIFFICULTY_MARKET, + } = options; + + if (!(Number.isFinite(rawDifficulty) && rawDifficulty > 0)) { + throw new Error("Target difficulty must be a positive number"); + } + + const targetMu = Math.log(rawDifficulty); + const targetVariance = options.targetVariance ?? market.variance; + if (!(Number.isFinite(targetVariance) && targetVariance > 0)) { + throw new Error("Invalid target variance"); + } + + const current = LognormalDistribution.create( + SQ128x128.fromNumber(market.mu)!, + SQ128x128.fromNumber(market.variance)!, + ); + const candidate = LognormalDistribution.create( + SQ128x128.fromNumber(targetMu)!, + SQ128x128.fromNumber(targetVariance)!, + ); + if (!(current && candidate)) { + throw new Error("Failed to build lognormal distributions"); + } + + // Locate x* with the package Newton helper, then scale collateral by λ. + const min = findLognormalMinimum(current, candidate); + if (!min.converged || !Number.isFinite(min.collateral)) { + throw new Error("Collateral solver failed for this target"); + } + + const lambdaF = lognormalLambda(market.mu, market.variance, market.effectiveK); + const lambdaG = lognormalLambda( + targetMu, + targetVariance, + market.effectiveK, + ); + // Scale the PDF-difference collateral into market units via mean λ. + const scale = Math.max(lambdaF, lambdaG, market.effectiveK); + const scaledCollateral = Math.max(0, min.collateral) * scale; + const buffered = scaledCollateral * (1 + bufferPercent / 100); + + const collateralSq = SQ128x128.fromNumber(buffered); + const xStarSq = SQ128x128.fromNumber(min.xStar); + if (!(collateralSq && xStarSq)) { + throw new Error("Failed to encode collateral / x*"); + } + + const hints = computeLognormalHints(candidate.sigma); + if (!hints) { + throw new Error("Failed to compute lognormal hints"); + } + + const callData = new CallData(LOGNORMAL_AMM_ABI); + const tradeCalldata = callData.compile("execute_trade", { + candidate: { + mu: toAbiSq128(candidate.toRaw().mu), + variance: toAbiSq128(candidate.toRaw().variance), + sigma: toAbiSq128(candidate.toRaw().sigma), + }, + x_star: toAbiSq128(xStarSq.toRaw()), + supplied_collateral: toAbiSq128(collateralSq.toRaw()), + candidate_hints: { + l2_norm_denom: toAbiSq128(hints.l2_norm_denom), + backing_denom: toAbiSq128(hints.backing_denom), + }, + }); + + const tradeCall: Call = { + contractAddress: toHexAddress(marketAddress), + entrypoint: "execute_trade", + calldata: tradeCalldata, + }; + + const tokenAmount = toTokenAmountUp(buffered, COLLATERAL_DECIMALS); + if (tokenAmount < MIN_TRADE_RAW) { + throw new Error( + `Minimum trade is ${(Number(MIN_TRADE_RAW) / 1e8).toFixed(6)} BTC`, + ); + } + + // +5% on approve only — do not bump supplied_collateral for the wallet fee. + const approveCall = buildApproveCall( + COLLATERAL_TOKEN, + marketAddress, + tokenAmount, + ); + + return { + targetMu, + targetVariance, + targetSigma: candidate.sigma.toNumber(), + xStar: min.xStar, + collateral: buffered, + tokenAmount, + calls: [approveCall, tradeCall], + summary: `Target difficulty ${rawDifficulty.toExponential(4)} (ln=${targetMu.toFixed(4)}), collateral ${buffered.toFixed(6)} BTC`, + }; +} diff --git a/desktop/src/features/markets/ui/MarketsScreen.tsx b/desktop/src/features/markets/ui/MarketsScreen.tsx new file mode 100644 index 00000000000..d1e11b1fd3a --- /dev/null +++ b/desktop/src/features/markets/ui/MarketsScreen.tsx @@ -0,0 +1,255 @@ +import * as React from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { toast } from "sonner"; + +import { invokeTauri } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; + +import { + DIFFICULTY_MARKET, + LN_MAX_SATS, + LN_MIN_SATS, + MARKET_TITLE, + MIN_TRADE_RAW, + PRODUCT_INDEXER_URL, +} from "./lib/constants"; +import { createFundLightningQuote } from "./lib/fundLightning"; +import { bettingHalted } from "./lib/halt"; +import { + fetchIndexerHealth, + fetchIndexerMarkets, + resolveIndexerUrl, + type IndexerMarket, +} from "./lib/indexer"; +import { placeBet } from "./lib/placeBet"; + +type Tab = "bet" | "fund"; + +async function fetchBitcoinHeight(): Promise { + const res = await fetch("https://mempool.space/api/blocks/tip/height"); + if (!res.ok) { + throw new Error("Could not read Bitcoin tip height"); + } + return Number(await res.text()); +} + +export function MarketsScreen() { + const [tab, setTab] = React.useState("bet"); + const [market, setMarket] = React.useState(null); + const [height, setHeight] = React.useState(null); + const [halted, setHalted] = React.useState(false); + const [targetDifficulty, setTargetDifficulty] = React.useState(""); + const [collateralBtc, setCollateralBtc] = React.useState("0.001"); + const [busy, setBusy] = React.useState(false); + const [fundSats, setFundSats] = React.useState("10000"); + const [invoice, setInvoice] = React.useState(null); + const [walletAddress, setWalletAddress] = React.useState(null); + const [loadError, setLoadError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const base = resolveIndexerUrl(); + await fetchIndexerHealth(base); + const markets = await fetchIndexerMarkets(base); + const found = + markets.find( + (m) => m.address.toLowerCase() === DIFFICULTY_MARKET.toLowerCase(), + ) ?? markets[0] ?? null; + const tip = await fetchBitcoinHeight(); + if (cancelled) return; + setMarket(found); + setHeight(tip); + setHalted(bettingHalted(tip)); + if (found?.state?.mean != null) { + // Indexer mean for lognormal is μ = ln(D); show D on the axis. + const d = Math.exp(found.state.mean); + if (Number.isFinite(d) && d > 0) { + setTargetDifficulty(d.toExponential(4)); + } + } + } catch (e) { + if (!cancelled) { + setLoadError(e instanceof Error ? e.message : String(e)); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const onPlaceBet = React.useCallback(async () => { + if (!market?.state || height == null) { + toast.error("Market not ready"); + return; + } + if (halted) { + toast.error("Betting is paused until after the next difficulty retarget"); + return; + } + const rawDifficulty = Number(targetDifficulty); + if (!(rawDifficulty > 0)) { + toast.error("Enter a positive target difficulty"); + return; + } + const collateral = Number(collateralBtc); + if (!(collateral > 0)) { + toast.error("Enter a BTC amount"); + return; + } + const raw = BigInt(Math.ceil(collateral * 1e8)); + if (raw < MIN_TRADE_RAW) { + toast.error( + `Minimum is ${(Number(MIN_TRADE_RAW) / 1e8).toFixed(6)} BTC`, + ); + return; + } + + setBusy(true); + try { + const result = await placeBet({ + rawDifficulty, + bitcoinHeight: height, + market: { + mu: market.state.mean ?? 0, + variance: market.state.variance ?? 0.01, + sigma: market.state.sigma ?? Math.sqrt(market.state.variance ?? 0.01), + effectiveK: market.state.effectiveK ?? market.state.k ?? 1, + }, + }); + toast.success(`Bet placed · ${result.summary}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [collateralBtc, halted, height, market, targetDifficulty]); + + const onFund = React.useCallback(async () => { + const sats = BigInt(fundSats || "0"); + if (sats < LN_MIN_SATS || sats > LN_MAX_SATS) { + toast.error(`Amount must be ${LN_MIN_SATS}–${LN_MAX_SATS} sats`); + return; + } + setBusy(true); + setInvoice(null); + try { + // Rust: human address only. Atomiq runs in JS on the Fund screen. + const wallet = await invokeTauri<{ + address: string; + }>("fund_lightning", { amountSats: Number(sats) }); + setWalletAddress(wallet.address); + + const rpc = + import.meta.env.VITE_STARKNET_RPC_URL ?? + "https://mainnet.nodes.starknet.org/rpc/v0_10"; + const quote = await createFundLightningQuote({ + amountSats: sats, + destinationAddress: wallet.address, + starknetRpcUrl: rpc, + }); + setInvoice(quote.invoice); + toast.success("Lightning invoice ready — pay to add bitcoin"); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [fundSats]); + + return ( +
+
+

Markets

+

{MARKET_TITLE}

+

+ Indexer {PRODUCT_INDEXER_URL} + {height != null ? ` · Bitcoin tip ${height}` : null} + {halted ? " · betting paused near retarget" : null} +

+
+ + {loadError ? ( +
+ {loadError} +
+ ) : null} + +
+ + +
+ + {tab === "bet" ? ( +
+

+ Pick a target mean on the raw Bitcoin difficulty axis. Collateral is + BTC. Betting pauses 24 blocks before each difficulty retarget. +

+ + + +
+ ) : ( +
+

+ Fund with Lightning. Pays into your wallet as BTC. This screen never + places a bet. +

+ + + {walletAddress ? ( +

+ Destination ready +

+ ) : null} + {invoice ? ( +
+ +

{invoice}

+
+ ) : null} +
+ )} +
+ ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 227572febe5..27c8531ae85 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -103,6 +103,7 @@ type AppSidebarProps = { | "agents" | "workflows" | "pulse" + | "markets" | "projects"; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; @@ -143,6 +144,7 @@ type AppSidebarProps = { onCreateAgent: () => void; onSelectAgents: () => void; onSelectProjects: () => void; + onSelectMarkets: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; onSelectHome: () => void; @@ -211,6 +213,7 @@ export function AppSidebar({ onCreateAgent, onSelectAgents, onSelectProjects, + onSelectMarkets, onSelectPulse, onSelectWorkflows, onSelectHome, @@ -611,6 +614,7 @@ export function AppSidebar({ onSelectAgents={onSelectAgents} onSelectHome={onSelectHome} onSelectProjects={onSelectProjects} + onSelectMarkets={onSelectMarkets} onSelectPulse={onSelectPulse} onSelectWorkflows={onSelectWorkflows} selectedView={selectedView} diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 4a618fcf0a4..565ef383ea0 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -19,6 +19,7 @@ type SidebarSelectedView = | "agents" | "workflows" | "pulse" + | "markets" | "projects"; type AppSidebarPinnedHeaderProps = { @@ -41,6 +42,7 @@ type AppSidebarPrimaryMenuProps = { homeBadgeCount: number; onSelectAgents: () => void; onSelectHome: () => void; + onSelectMarkets: () => void; onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; @@ -90,6 +92,7 @@ export function AppSidebarPrimaryMenu({ homeBadgeCount, onSelectAgents, onSelectHome, + onSelectMarkets, onSelectProjects, onSelectPulse, onSelectWorkflows, @@ -130,6 +133,18 @@ export function AppSidebarPrimaryMenu({ ) : null} + + + + Markets + + `) **do not** get Starknet accounts. + +## Fund (Lightning only) + +`fund_lightning` (Tauri) returns the human counterfactual address. The Fund +screen uses Atomiq `@atomiqlabs/sdk` `FROM_BTCLN_AUTO` into that address +(token product-labeled **BTC**, min 100 / max 2_000_000 sats). Optional +`gasAmount` STRK drop is fallback only — AVNU sponsors gas for bets. + +## Bet (Starknet only — hidden) + +`place_bet` never creates an LN invoice, zap, or Atomiq swap. Flow: + +1. JS `prepareLognormalTrade`: UI axis is raw difficulty `D`; candidate μ is + `ln(D)`. Hints set **both** `l2_norm_denom` and `backing_denom` to + `isqrt(2·σ·√π)` (same limbs). Normal `computeHints` must not be used. +2. Calls = `[feeCall, approve(+5%), execute_trade]`. Wallet fee is 10 bps + (min 1 sat) as a separate transfer — do not bump approve or + `supplied_collateral`. +3. Rust signs SNIP-12 OutsideExecution with BIP-340 (`sign_tx_hash`) and + submits via the AVNU proxy (`feeMode: sponsored`). + +Halt: wallet disables betting 24 Bitcoin blocks before the next retarget +height (every 2016 blocks). + +## INDEXER_URL + +Required for deploy. Production: + +```text +INDEXER_URL=https://markets.bitcoinmarkets.app +``` + +- `GET {INDEXER_URL}/api/markets` +- `GET {INDEXER_URL}/health` + +Never ship a loopback default (`http://127.0.0.1:8787` is listing-proof only). +The desktop client uses the product host above (overridable via +`VITE_INDEXER_URL` / `INDEXER_URL`) and refuses loopback. + +## AVNU_API_KEY + +Set only on `buzz-avnu-proxy`: + +```text +AVNU_API_KEY=… # from portal.avnu.fi — never commit +AVNU_PAYMASTER_URL=https://starknet.paymaster.avnu.fi +BIND_ADDR=0.0.0.0:8788 +``` + +Run: `cargo run -p buzz-avnu-proxy`. Desktop uses `AVNU_PROXY_URL` to reach +the proxy; the key never enters the Tauri binary. diff --git a/infra/aws/markets.tf.md b/infra/aws/markets.tf.md new file mode 100644 index 00000000000..b8fb9edba70 --- /dev/null +++ b/infra/aws/markets.tf.md @@ -0,0 +1,19 @@ +# Markets indexer (listing) + +# Hostname is locked for the Bitcoin Markets product: +# https://markets.bitcoinmarkets.app +# +# Clients read INDEXER_URL (required in deploy). Production value: +# INDEXER_URL=https://markets.bitcoinmarkets.app +# +# Endpoints: +# GET {INDEXER_URL}/health +# GET {INDEXER_URL}/api/markets +# +# Never default INDEXER_URL to http://127.0.0.1:8787 — loopback is +# listing-proof only. Infra wiring for the indexer service lands here; +# the hostname above is stable even while DNS propagates. + +# Placeholder: ECS/service modules for the indexer will be added alongside +# the existing relay/paymaster stacks. Until then, point INDEXER_URL at the +# product host above. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8be559938c..08bf91a40ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,12 @@ importers: desktop: dependencies: + '@atomiqlabs/chain-starknet': + specifier: ^8.6.0 + version: 8.6.0(starknet@9.4.2(typescript@6.0.3)(zod@4.4.3)) + '@atomiqlabs/sdk': + specifier: ^8.9.6 + version: 8.9.6(typescript@6.0.3) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -156,6 +162,18 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2.10.0 version: 2.10.1 + '@the-situation/artifacts': + specifier: ^0.15.0 + version: 0.15.0(starknet@9.4.2(typescript@6.0.3)(zod@4.4.3)) + '@the-situation/collateral': + specifier: ^0.12.1 + version: 0.12.1 + '@the-situation/core': + specifier: ^0.12.0 + version: 0.12.0 + '@the-situation/utils': + specifier: ^0.13.0 + version: 0.13.0(typescript@6.0.3)(zod@4.4.3) '@tiptap/core': specifier: ^3.22.3 version: 3.22.5(@tiptap/pm@3.22.5) @@ -228,6 +246,9 @@ importers: sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + starknet: + specifier: ^9.2.1 + version: 9.4.2(typescript@6.0.3)(zod@4.4.3) tailwind-merge: specifier: ^3.5.0 version: 3.6.0 @@ -404,6 +425,9 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -417,6 +441,29 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@atomiqlabs/base@13.6.0': + resolution: {integrity: sha512-ellgTn7sdqHmQFaUj8V0H/XNDfoYzZG7z+l6aO5a63Y76JexXCR1m+p+8mRF0iPRSgZXvhcGrCTJSud0an4elg==} + + '@atomiqlabs/bolt11@1.6.1': + resolution: {integrity: sha512-glzUVsYKTenPbIflI3Dmu3MHibc1b6iR4DaeX3fFeh5dnv0XflhFcxpcGG36GSW0KQbB2hKK36bXSuSleoFizw==} + peerDependencies: + '@noble/curves': ^1.8.1 + '@noble/hashes': ^1.7.1 + + '@atomiqlabs/btc-mempool@1.1.2': + resolution: {integrity: sha512-V8S/hVs5kOGfDxMvWgX3ciSHqQVjZZH3KSRC1hXvJamsM19MO+utWxogL0Fk/3K3PiLVtdxrSy0+WpvKJIwylA==} + + '@atomiqlabs/chain-starknet@8.6.0': + resolution: {integrity: sha512-N/Hac94yDkkiVGK3tFK5Ohza/STlfUSqNPAHceD3zEkeFED9fJN1ismyrWGN/0T5D8dYBryrZLPsEU1iD+AuRQ==} + peerDependencies: + starknet: ^9.0.0 + + '@atomiqlabs/messenger-nostr@2.0.1': + resolution: {integrity: sha512-bAtya1kjLjsBsF+Yb2JARHZ9/i/E+seBfc1tDgKrSVX8b2IxAe8Hfajgsk1h2BMRZzkJk/d0SS6OFpXKJ5RHCg==} + + '@atomiqlabs/sdk@8.9.6': + resolution: {integrity: sha512-7rcvZeRdiRv4m8Di+47WloRwG9RXJoi4UpUGFGVcjNMmcV/oCYSdFv64H0cRYWc//uZm9wySxQRSBTKFYBkyog==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -707,14 +754,63 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@0.5.3': + resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} + '@noble/curves@1.1.0': + resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/curves@1.7.0': + resolution: {integrity: sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.8.2': + resolution: {integrity: sha512-vnI7V6lFNe0tLAuJMu+2sX+FcL14TaCWy1qiczg1VwRmPrpQCdq5ESXQMqUc2tluRNf6irBXrWbl1mGN8uaU/g==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.0.1': resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} engines: {node: '>= 20.19.0'} + '@noble/hashes@1.3.1': + resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} + engines: {node: '>= 16'} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + '@noble/hashes@1.6.0': + resolution: {integrity: sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.6.1': + resolution: {integrity: sha512-pq5D8h10hHBjyqX+cfBm0i8JUXJ0UhczFc4r74zbuT9XgewFo2E3J1cOaGtdZynILNmQ685YWGzGE1Zv6io50w==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.7.2': + resolution: {integrity: sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.1': resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} @@ -1558,15 +1654,45 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scure/base@1.1.1': + resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} + + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + '@scure/base@2.0.0': resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + '@scure/bip32@1.3.1': + resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} + + '@scure/bip32@1.7.0': + resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + '@scure/bip32@2.0.1': resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + '@scure/bip39@1.2.1': + resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + + '@scure/bip39@1.6.0': + resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@scure/bip39@2.0.1': resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@scure/btc-signer@1.6.0': + resolution: {integrity: sha512-qd6ciJE4Onk1xdQEdjPvRbLRrH7EddPZagMuZOFv77R/76EWixENd6nuoxqHNEPGRbS09rgAhhPgT7j0oQdi1A==} + + '@scure/btc-signer@1.7.0': + resolution: {integrity: sha512-GqwwBp05GLTJQ1Ja6am64GhDSFqKJrFbavMt/UZF4pNn/gXbZUcuWjJA4g7qtOudW1MGRYkIO5JsPWi6JmSoQw==} + + '@scure/btc-signer@1.8.1': + resolution: {integrity: sha512-8nX9T++dFyKpvqksNHfSi9CgRsGnHAQtCdIQ1y1GmbCGLpV97v4MUyemUUT6uDumKL3oo3m4niyY6A32nmdLuQ==} + + '@scure/starknet@1.1.0': + resolution: {integrity: sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ==} + '@shikijs/core@4.1.0': resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} engines: {node: '>=20'} @@ -1601,6 +1727,18 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@starknet-io/get-starknet-wallet-standard@5.0.0': + resolution: {integrity: sha512-isDNGDlp16W24HE4IuweYXLDRZN0JbsDnazAieeKXE87Mn+jqhsjgTsMxcwWTjX7v906Bjz39FiDjGUddnr36g==} + + '@starknet-io/types-js@0.10.0': + resolution: {integrity: sha512-7ALSydz6pq3YIOpq5a7OkkxqwJciMc9Nlph0OGjhcC3xX0xH30XgizmziLyYVN10oO9+BJk8M9KbJjpzdbtRSw==} + + '@starknet-io/types-js@0.7.10': + resolution: {integrity: sha512-1VtCqX4AHWJlRRSYGSn+4X1mqolI1Tdq62IwzoU2vUuEE72S1OlEeGhpvd6XsdqXcfHmVzYfj8k1XtKBQqwo9w==} + + '@starknet-io/types-js@0.9.2': + resolution: {integrity: sha512-vWOc0FVSn+RmabozIEWcEny1I73nDGTvOrLYJsR1x7LGA3AZmqt4i/aW69o/3i2NN5CVP8Ok6G1ayRQJKye3Wg==} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -1886,6 +2024,23 @@ packages: '@types/react-dom': optional: true + '@the-situation/abi@0.15.0': + resolution: {integrity: sha512-kZUeJWgakDF4dP6Tdm7nhJnq9elrF3NLPPSpQfWCubkr6+j7JrX26xcNjQ+kyt6YttmgGGOJIK88tqDGkF5z5w==} + + '@the-situation/artifacts@0.15.0': + resolution: {integrity: sha512-dcIH/myLdUADMxnd+lsiHu/+dCicOT2OjSt1YCI3O7ApgTRoEurz3GKx/f1V2MYgZKaVvQ6Ya3dpv/AFgFnaoQ==} + peerDependencies: + starknet: ^9.2.1 + + '@the-situation/collateral@0.12.1': + resolution: {integrity: sha512-fB0Vsu5mQHQbutnjZaJ0Fp3NasXq+8pCkaBIc/kxABunYNmrshkQGCP0QYYYL9csjYILNxnt5RfL0AF6jxp+uA==} + + '@the-situation/core@0.12.0': + resolution: {integrity: sha512-5gooFnU6XtThKZlJ3SF95COOZbb/CZSMZvtidH1shB1WkB4Lso7Op4HNNQEgbIXy/lw8FBqFU4oMyuNsW6bKOg==} + + '@the-situation/utils@0.13.0': + resolution: {integrity: sha512-6vajHK2ISr6sJxiB6Gx0lOzw9NfMMAMclawzL3gE+ZosxSLLZJnorRmOiAKBsyDY4n4X1SkfYM8GCSGtHKCg2g==} + '@tiptap/core@3.22.5': resolution: {integrity: sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==} peerDependencies: @@ -2154,6 +2309,29 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@wallet-standard/base@1.1.1': + resolution: {integrity: sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==} + engines: {node: '>=22'} + + '@wallet-standard/features@1.1.1': + resolution: {integrity: sha512-aCWYmVeSCGViyEU5k7GMoW8zxE4Gs+C1s1Pp2XLesvSNlnZ4PMES9HUnTB3hl0b3RVj7C61yze3IWyrncqg4MA==} + engines: {node: '>=22'} + + abi-wan-kanabi@2.2.4: + resolution: {integrity: sha512-0aA81FScmJCPX+8UvkXLki3X1+yPQuWxEkqXBVKltgPAK79J+NB+Lp5DouMXa7L6f+zcRlIA/6XO7BN/q9fnvg==} + hasBin: true + + abitype@1.3.0: + resolution: {integrity: sha512-fk6Te+bojIFrMvMZrnOO+SxCB+RUksTGOzq/60ZRvs1L+BVzvi2bqt9L3W/17ZLdZsyM1FuYf65P5nlmoiH1Bg==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -2174,6 +2352,9 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansicolors@0.3.2: + resolution: {integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==} + ansis@4.3.0: resolution: {integrity: sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==} engines: {node: '>=14'} @@ -2217,6 +2398,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bech32@1.1.4: + resolution: {integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -2250,6 +2434,10 @@ packages: canvas-renderer@2.2.1: resolution: {integrity: sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg==} + cardinal@2.1.1: + resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2285,6 +2473,10 @@ packages: cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2397,6 +2589,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + effect@3.22.1: + resolution: {integrity: sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ==} + electron-to-chromium@1.5.361: resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==} @@ -2454,6 +2649,11 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} @@ -2464,6 +2664,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2475,6 +2678,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} @@ -2513,6 +2720,10 @@ packages: react-dom: optional: true + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2692,6 +2903,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + just-debounce-it@1.1.0: resolution: {integrity: sha512-87Nnc0qZKgBZuhFZjYVjSraic0x7zwjhaTMrCKlj0QYKH6lh0KbFzVnfu6LHan03NO7J8ygjeBeD0epejn5Zcg==} @@ -2772,6 +2986,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + linked-list-typescript@1.0.15: + resolution: {integrity: sha512-RIyUu9lnJIyIaMe63O7/aFv/T2v3KsMFuXMBbUQCHX+cgtGro86ETDj5ed0a8gQL2+DFjzYYsgVG4I36/cUwgw==} + linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} @@ -2782,6 +2999,9 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -2792,6 +3012,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lossless-json@4.3.1: + resolution: {integrity: sha512-SqD/Bg3ZfltBJ2Z14hJ/BihnvtV553WO4g9/ePtlp4lrnl9jF3AdIJt53A/Wkg/0Li+LMfxaBqgx1MiFZdQlpQ==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -2879,6 +3102,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + micro-packed@0.7.3: + resolution: {integrity: sha512-2Milxs+WNC00TRlem41oRswvw31146GiSaoCT7s3Xi2gMUglW5QBeqlQaZeHr5tJx9nm3i57LNXPqxOOaWtTYg==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -3014,6 +3240,14 @@ packages: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} + nostr-tools@2.20.0: + resolution: {integrity: sha512-Kq/2lMyeOdGvpDsYH2an8HP4H0aFCqwKythhTzxfgZTVv4L3NOgrJw2SxH8jkWlH8xPhWxGfN6lFtC+EAa2qYQ==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + nostr-tools@2.23.12: resolution: {integrity: sha512-dLE9r0b4pCmrOKLUPD0KhUj9IXeh6RwiYoonEWeBh2AaDmlczUjJqJC8pQfggXzKUZ7+uZvhcrKkfwuZk0/e1g==} peerDependencies: @@ -3041,6 +3275,14 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + ox@0.4.4: + resolution: {integrity: sha512-oJPEeCDs9iNiPs6J0rTx+Y0KGeCGyCAA3zo94yZhm8G5WpOxrwUtn2Ie/Y8IyARSqqY/j9JTKA3Fc1xs1DvFnw==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -3056,6 +3298,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3127,6 +3372,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + promise-queue-ts@1.0.0: + resolution: {integrity: sha512-hNdPrYEdTfdv2nKU41qhqPnfw6lLSA4DBh88uYzQ054mQBc5IEwFHbxEC/gXMV0TCM9TO7Ri1InJloDKGRU7Eg==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -3177,6 +3425,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode.react@4.2.0: resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} peerDependencies: @@ -3187,6 +3438,9 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + queue-typescript@1.0.1: + resolution: {integrity: sha512-tkK08uPfmpPl0cX1WRSU3EoNb/T5zSoZPGkkpfGX4E8QayWvEmLS2cI3pFngNPkNTCU5pCDQ1IwlzN0L5gdFPg==} + react-diff-view@3.3.3: resolution: {integrity: sha512-CPveApk6n7ZbkW7T6PoptR7LWAvD9hohTHZ7WnKnu3GZkTfUB5rvg486apPo94iYVi4fZd3Nt+rtBZ5877exoQ==} peerDependencies: @@ -3252,6 +3506,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redeyed@2.1.1: + resolution: {integrity: sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -3363,6 +3620,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + starknet@9.4.2: + resolution: {integrity: sha512-NFtg077DjddHUSh8sLZ5uB149LEF4NR90FuJ0oF7+zhce7l0oG9Ubqr3H4+jHm/ghHtV+yjlv1UhEoo4SBfILA==} + engines: {node: '>=22'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -3452,6 +3713,9 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3491,6 +3755,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unplugin@3.0.0: resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3687,6 +3955,10 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3712,6 +3984,10 @@ packages: y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3724,10 +4000,18 @@ packages: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yargs@15.4.1: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} @@ -3740,6 +4024,8 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@adraffy/ens-normalize@1.11.1': {} + '@alloc/quick-lru@5.2.0': {} '@asamuzakjp/css-color@4.1.2': @@ -3760,6 +4046,68 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@atomiqlabs/base@13.6.0': + dependencies: + buffer: 6.0.3 + + '@atomiqlabs/bolt11@1.6.1(@noble/curves@1.9.7)(@noble/hashes@1.8.0)': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/btc-signer': 1.7.0 + bech32: 1.1.4 + lodash.clonedeep: 4.5.0 + safe-buffer: 5.2.1 + + '@atomiqlabs/btc-mempool@1.1.2': + dependencies: + '@atomiqlabs/base': 13.6.0 + '@scure/btc-signer': 1.6.0 + + '@atomiqlabs/chain-starknet@8.6.0(starknet@9.4.2(typescript@6.0.3)(zod@4.4.3))': + dependencies: + '@atomiqlabs/base': 13.6.0 + '@noble/hashes': 1.8.0 + '@scure/btc-signer': 1.8.1 + abi-wan-kanabi: 2.2.4 + buffer: 6.0.3 + promise-queue-ts: 1.0.0 + starknet: 9.4.2(typescript@6.0.3)(zod@4.4.3) + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@atomiqlabs/messenger-nostr@2.0.1(typescript@6.0.3)': + dependencies: + '@atomiqlabs/base': 13.6.0 + nostr-tools: 2.20.0(typescript@6.0.3) + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@atomiqlabs/sdk@8.9.6(typescript@6.0.3)': + dependencies: + '@atomiqlabs/base': 13.6.0 + '@atomiqlabs/bolt11': 1.6.1(@noble/curves@1.9.7)(@noble/hashes@1.8.0) + '@atomiqlabs/btc-mempool': 1.1.2 + '@atomiqlabs/messenger-nostr': 2.0.1(typescript@6.0.3) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + '@scure/btc-signer': 1.7.0 + buffer: 6.0.3 + events: 3.3.0 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -4051,12 +4399,48 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/ciphers@0.5.3': {} + + '@noble/ciphers@1.3.0': {} + '@noble/ciphers@2.1.1': {} + '@noble/curves@1.1.0': + dependencies: + '@noble/hashes': 1.3.1 + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/curves@1.7.0': + dependencies: + '@noble/hashes': 1.6.0 + + '@noble/curves@1.8.2': + dependencies: + '@noble/hashes': 1.7.2 + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/curves@2.0.1': dependencies: '@noble/hashes': 2.0.1 + '@noble/hashes@1.3.1': {} + + '@noble/hashes@1.3.2': {} + + '@noble/hashes@1.6.0': {} + + '@noble/hashes@1.6.1': {} + + '@noble/hashes@1.7.2': {} + + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} '@noble/hashes@2.2.0': {} @@ -4798,19 +5182,71 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@scure/base@1.1.1': {} + + '@scure/base@1.2.6': {} + '@scure/base@2.0.0': {} + '@scure/bip32@1.3.1': + dependencies: + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + + '@scure/bip32@1.7.0': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@scure/bip32@2.0.1': dependencies: '@noble/curves': 2.0.1 '@noble/hashes': 2.0.1 '@scure/base': 2.0.0 + '@scure/bip39@1.2.1': + dependencies: + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + + '@scure/bip39@1.6.0': + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + '@scure/bip39@2.0.1': dependencies: '@noble/hashes': 2.0.1 '@scure/base': 2.0.0 + '@scure/btc-signer@1.6.0': + dependencies: + '@noble/curves': 1.8.2 + '@noble/hashes': 1.7.2 + '@scure/base': 1.2.6 + micro-packed: 0.7.3 + + '@scure/btc-signer@1.7.0': + dependencies: + '@noble/curves': 1.8.2 + '@noble/hashes': 1.7.2 + '@scure/base': 1.2.6 + micro-packed: 0.7.3 + + '@scure/btc-signer@1.8.1': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + micro-packed: 0.7.3 + + '@scure/starknet@1.1.0': + dependencies: + '@noble/curves': 1.7.0 + '@noble/hashes': 1.6.1 + '@shikijs/core@4.1.0': dependencies: '@shikijs/primitive': 4.1.0 @@ -4853,6 +5289,22 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@starknet-io/get-starknet-wallet-standard@5.0.0(typescript@6.0.3)(zod@4.4.3)': + dependencies: + '@starknet-io/types-js': 0.7.10 + '@wallet-standard/base': 1.1.1 + '@wallet-standard/features': 1.1.1 + ox: 0.4.4(typescript@6.0.3)(zod@4.4.3) + transitivePeerDependencies: + - typescript + - zod + + '@starknet-io/types-js@0.10.0': {} + + '@starknet-io/types-js@0.7.10': {} + + '@starknet-io/types-js@0.9.2': {} + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 @@ -5114,6 +5566,32 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) + '@the-situation/abi@0.15.0': + dependencies: + '@the-situation/core': 0.12.0 + + '@the-situation/artifacts@0.15.0(starknet@9.4.2(typescript@6.0.3)(zod@4.4.3))': + dependencies: + starknet: 9.4.2(typescript@6.0.3)(zod@4.4.3) + + '@the-situation/collateral@0.12.1': + dependencies: + '@the-situation/core': 0.12.0 + effect: 3.22.1 + + '@the-situation/core@0.12.0': + dependencies: + effect: 3.22.1 + + '@the-situation/utils@0.13.0(typescript@6.0.3)(zod@4.4.3)': + dependencies: + '@the-situation/abi': 0.15.0 + '@the-situation/core': 0.12.0 + starknet: 9.4.2(typescript@6.0.3)(zod@4.4.3) + transitivePeerDependencies: + - typescript + - zod + '@tiptap/core@3.22.5(@tiptap/pm@3.22.5)': dependencies: '@tiptap/pm': 3.22.5 @@ -5407,6 +5885,24 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@wallet-standard/base@1.1.1': {} + + '@wallet-standard/features@1.1.1': + dependencies: + '@wallet-standard/base': 1.1.1 + + abi-wan-kanabi@2.2.4: + dependencies: + ansicolors: 0.3.2 + cardinal: 2.1.1 + fs-extra: 10.1.0 + yargs: 17.7.3 + + abitype@1.3.0(typescript@6.0.3)(zod@4.4.3): + optionalDependencies: + typescript: 6.0.3 + zod: 4.4.3 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -5421,6 +5917,8 @@ snapshots: ansi-styles@5.2.0: {} + ansicolors@0.3.2: {} + ansis@4.3.0: {} argparse@2.0.1: {} @@ -5458,6 +5956,8 @@ snapshots: baseline-browser-mapping@2.10.32: {} + bech32@1.1.4: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -5500,6 +6000,11 @@ snapshots: dependencies: '@types/node': 25.6.0 + cardinal@2.1.1: + dependencies: + ansicolors: 0.3.2 + redeyed: 2.1.1 + ccount@2.0.1: {} chai@6.2.2: {} @@ -5530,6 +6035,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 6.2.0 + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -5619,6 +6130,11 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + effect@3.22.1: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + electron-to-chromium@1.5.361: {} embla-carousel-react@8.6.0(react@19.2.8): @@ -5660,6 +6176,8 @@ snapshots: escape-string-regexp@5.0.0: {} + esprima@4.0.1: {} + estree-util-is-identifier-name@3.0.0: {} estree-walker@3.0.3: @@ -5668,12 +6186,18 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.1: {} + events@3.3.0: {} expect-type@1.4.0: {} extend@3.0.2: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-equals@5.4.0: {} fast-text-encoding@1.0.6: {} @@ -5704,6 +6228,12 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.2: optional: true @@ -5913,6 +6443,12 @@ snapshots: json5@2.2.3: {} + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + just-debounce-it@1.1.0: {} just-once@1.1.0: {} @@ -5966,6 +6502,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + linked-list-typescript@1.0.15: {} + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -5976,6 +6514,8 @@ snapshots: dependencies: p-locate: 4.1.0 + lodash.clonedeep@4.5.0: {} + lodash@4.18.1: {} longest-streak@3.1.0: {} @@ -5984,6 +6524,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lossless-json@4.3.1: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -6177,6 +6719,10 @@ snapshots: mdurl@2.0.0: {} + micro-packed@0.7.3: + dependencies: + '@scure/base': 1.2.6 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -6400,6 +6946,18 @@ snapshots: node-releases@2.0.46: {} + nostr-tools@2.20.0(typescript@6.0.3): + dependencies: + '@noble/ciphers': 0.5.3 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 6.0.3 + nostr-tools@2.23.12(typescript@6.0.3): dependencies: '@noble/ciphers': 2.1.1 @@ -6430,6 +6988,20 @@ snapshots: orderedmap@2.1.1: {} + ox@0.4.4(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.7.0 + '@noble/hashes': 1.6.1 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.3.0(typescript@6.0.3)(zod@4.4.3) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - zod + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -6442,6 +7014,8 @@ snapshots: pako@1.0.11: {} + pako@2.2.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -6507,6 +7081,10 @@ snapshots: process@0.11.10: {} + promise-queue-ts@1.0.0: + dependencies: + queue-typescript: 1.0.1 + property-information@7.1.0: {} prosemirror-changeset@2.4.1: @@ -6588,6 +7166,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + qrcode.react@4.2.0(react@19.2.8): dependencies: react: 19.2.8 @@ -6598,6 +7178,10 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + queue-typescript@1.0.1: + dependencies: + linked-list-typescript: 1.0.15 + react-diff-view@3.3.3(react@19.2.8): dependencies: classnames: 2.5.1 @@ -6677,6 +7261,10 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redeyed@2.1.1: + dependencies: + esprima: 4.0.1 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -6823,6 +7411,23 @@ snapshots: stackback@0.0.2: {} + starknet@9.4.2(typescript@6.0.3)(zod@4.4.3): + dependencies: + '@noble/curves': 1.7.0 + '@noble/hashes': 1.6.1 + '@scure/base': 1.2.6 + '@scure/starknet': 1.1.0 + '@starknet-io/get-starknet-wallet-standard': 5.0.0(typescript@6.0.3)(zod@4.4.3) + '@starknet-io/starknet-types-010': '@starknet-io/types-js@0.10.0' + '@starknet-io/starknet-types-09': '@starknet-io/types-js@0.9.2' + abi-wan-kanabi: 2.2.4 + lossless-json: 4.3.1 + pako: 2.2.0 + ts-mixer: 6.0.4 + transitivePeerDependencies: + - typescript + - zod + std-env@4.2.0: {} string-width@4.2.3: @@ -6912,6 +7517,8 @@ snapshots: trough@2.2.0: {} + ts-mixer@6.0.4: {} + tslib@2.8.1: {} tw-animate-css@1.4.0: {} @@ -6961,6 +7568,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} + unplugin@3.0.0: dependencies: '@jridgewell/remapping': 2.3.5 @@ -7100,6 +7709,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@8.21.1: {} @@ -7110,6 +7725,8 @@ snapshots: y18n@4.0.3: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@2.9.0: {} @@ -7119,6 +7736,8 @@ snapshots: camelcase: 5.3.1 decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: dependencies: cliui: 6.0.0 @@ -7133,6 +7752,16 @@ snapshots: y18n: 4.0.3 yargs-parser: 18.1.3 + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + zod@4.4.3: {} zwitch@2.0.4: {} From 071a38cdcad9fbdcb4173b533f0cc2baa0f52b8a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 12:57:41 +0000 Subject: [PATCH 02/13] fix(markets): lock INDEXER_URL to markets.bitcoinmarkets.app Require the product host as INDEXER_URL with no localhost default. Refuse 127.0.0.1:8787 in Rust/JS resolvers. Document that Markets is adding the indexer in infra/aws while the hostname stays locked during DNS propagation. Co-authored-by: Adrien Lacombe --- .env.example | 7 ++- crates/buzz-avnu-proxy/src/main.rs | 11 ++-- crates/buzz-core/src/markets.rs | 59 ++++++++++++++++++- desktop/src-tauri/src/commands/markets.rs | 7 +-- desktop/src/features/markets/lib/constants.ts | 6 +- desktop/src/features/markets/lib/indexer.ts | 10 ++-- .../src/features/markets/lib/markets.test.mjs | 2 +- docs/bitcoin-markets.md | 10 ++-- infra/aws/README.md | 7 +++ infra/aws/markets.tf.md | 26 ++++---- 10 files changed, 108 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index aee5c34a8b9..5fb81ab6795 100644 --- a/.env.example +++ b/.env.example @@ -252,10 +252,11 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_AGE_ATTESTATION_REQUIRED=true # ── Bitcoin Markets (fork) ─────────────────────────────────────────────────── -# Market indexer base URL. Production host (required for deploy): +# Required env — no localhost default. Product host (domain bitcoinmarkets.app): # INDEXER_URL=https://markets.bitcoinmarkets.app -# Listing: GET $INDEXER_URL/api/markets and GET $INDEXER_URL/health -# Do NOT default this to http://127.0.0.1:8787 — loopback is listing-proof only. +# Hostname locked even while Markets adds the service in infra/aws and DNS +# still propagates. Listing: GET $INDEXER_URL/api/markets and GET $INDEXER_URL/health +# Do NOT ship http://127.0.0.1:8787 — loopback is listing-proof only. INDEXER_URL=https://markets.bitcoinmarkets.app # Server-side AVNU SNIP-29 paymaster proxy (buzz-avnu-proxy). diff --git a/crates/buzz-avnu-proxy/src/main.rs b/crates/buzz-avnu-proxy/src/main.rs index 2f08b244d89..942c8bdefe5 100644 --- a/crates/buzz-avnu-proxy/src/main.rs +++ b/crates/buzz-avnu-proxy/src/main.rs @@ -19,12 +19,11 @@ //! Test: https://sepolia.paymaster.avnu.fi //! BIND_ADDR Listen address. Default: 0.0.0.0:8788 //! -//! INDEXER_URL Product market indexer base URL. Required for deploy -//! of listing clients; production value is -//! https://markets.bitcoinmarkets.app +//! INDEXER_URL Required env for listing clients (no localhost +//! default). Production: https://markets.bitcoinmarkets.app //! (GET {INDEXER_URL}/api/markets, GET {INDEXER_URL}/health). -//! Do NOT default this to http://127.0.0.1:8787 — loopback -//! is listing-proof only. +//! Do NOT ship http://127.0.0.1:8787. Hostname is locked +//! while Markets adds the service in infra/aws. //! ``` use axum::{ @@ -89,7 +88,7 @@ async fn main() -> Result<(), BootError> { warn!( indexer_url = %url, product = PRODUCT_INDEXER_URL, - "INDEXER_URL points at loopback; production is https://markets.bitcoinmarkets.app" + "INDEXER_URL points at loopback; required product host is https://markets.bitcoinmarkets.app (no localhost default)" ); } Ok(url) => info!(indexer_url = %url, "INDEXER_URL set"), diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs index e3ff177006b..c312306b2a6 100644 --- a/crates/buzz-core/src/markets.rs +++ b/crates/buzz-core/src/markets.rs @@ -38,8 +38,18 @@ pub const RETARGET_INTERVAL: u64 = 2016; /// Halt betting this many blocks before the next retarget height. pub const HALT_BLOCKS_BEFORE_RETARGET: u64 = 24; -/// Product indexer host. Clients read `INDEXER_URL` (required in deploy) and -/// must never fall back to loopback — localhost is listing-proof only. +/// Product indexer host on `bitcoinmarkets.app`. +/// +/// Deploy must set `INDEXER_URL` to this value. Clients may fall back to the +/// constant when the env is unset, but must **never** invent +/// `http://127.0.0.1:8787` (loopback is listing-proof only). +/// +/// Listing endpoints: +/// - `GET {INDEXER_URL}/api/markets` +/// - `GET {INDEXER_URL}/health` +/// +/// Hostname is locked even while DNS for the Markets service in `infra/aws` +/// is still propagating. pub const PRODUCT_INDEXER_URL: &str = "https://markets.bitcoinmarkets.app"; /// Keyring entry name for the human identity nsec. @@ -54,6 +64,12 @@ pub enum MarketsError { /// A keyring name was empty or malformed. #[error("invalid keyring name: {0}")] InvalidKeyringName(String), + /// `INDEXER_URL` pointed at loopback; product host is required. + #[error( + "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app \ + (required env, no localhost default)" + )] + IndexerUrlLoopback, } /// Computes the wallet fee for a trade collateral amount. @@ -132,6 +148,29 @@ pub fn is_human_keyring_name(name: &str) -> bool { assert_human_keyring_name(name).is_ok() } +/// Resolve the markets indexer base URL. +/// +/// Prefer `INDEXER_URL` from the environment (required in deploy). When unset, +/// use [`PRODUCT_INDEXER_URL`] (`https://markets.bitcoinmarkets.app`). Never +/// invent `http://127.0.0.1:8787` — loopback is listing-proof only and is +/// refused if an operator sets it. +pub fn resolve_indexer_url() -> Result { + resolve_indexer_url_from(std::env::var("INDEXER_URL").ok().as_deref()) +} + +/// Resolve from an optional raw env value (tests / callers that already read env). +pub fn resolve_indexer_url_from(raw: Option<&str>) -> Result { + let chosen = raw + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(PRODUCT_INDEXER_URL); + let base = chosen.trim_end_matches('/'); + if base.contains("127.0.0.1") || base.to_ascii_lowercase().contains("localhost") { + return Err(MarketsError::IndexerUrlLoopback); + } + Ok(base.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -214,5 +253,21 @@ mod tests { assert!(!PRODUCT_INDEXER_URL.contains("127.0.0.1")); assert!(!PRODUCT_INDEXER_URL.contains("localhost")); assert_eq!(PRODUCT_INDEXER_URL, "https://markets.bitcoinmarkets.app"); + assert_eq!( + resolve_indexer_url_from(None).unwrap(), + PRODUCT_INDEXER_URL + ); + assert_eq!( + resolve_indexer_url_from(Some("https://markets.bitcoinmarkets.app/")).unwrap(), + "https://markets.bitcoinmarkets.app" + ); + assert_eq!( + resolve_indexer_url_from(Some("http://127.0.0.1:8787")), + Err(MarketsError::IndexerUrlLoopback) + ); + assert_eq!( + resolve_indexer_url_from(Some("http://localhost:8787")), + Err(MarketsError::IndexerUrlLoopback) + ); } } diff --git a/desktop/src-tauri/src/commands/markets.rs b/desktop/src-tauri/src/commands/markets.rs index 384a3fb9f24..2d5dec5d2b8 100644 --- a/desktop/src-tauri/src/commands/markets.rs +++ b/desktop/src-tauri/src/commands/markets.rs @@ -10,8 +10,8 @@ use crate::app_state::AppState; use buzz_core_pkg::markets::{ - assert_human_keyring_name, betting_halted, wallet_fee_amount, HUMAN_IDENTITY_KEYRING_NAME, - NOSTR_ACCOUNT_CLASS_HASH, PRODUCT_INDEXER_URL, + assert_human_keyring_name, betting_halted, resolve_indexer_url, wallet_fee_amount, + HUMAN_IDENTITY_KEYRING_NAME, NOSTR_ACCOUNT_CLASS_HASH, }; use buzz_core_pkg::outside_execution::{ any_caller, OutsideCall, OutsideExecution, @@ -252,8 +252,7 @@ pub async fn fund_lightning( class_hash: NOSTR_ACCOUNT_CLASS_HASH.to_string(), constructor_calldata: ctor.iter().map(|f| f.to_fixed_hex_string()).collect(), salt: DEPLOY_SALT.to_fixed_hex_string(), - indexer_url: std::env::var("INDEXER_URL") - .unwrap_or_else(|_| PRODUCT_INDEXER_URL.to_string()), + indexer_url: resolve_indexer_url().map_err(|e| e.to_string())?, }) } diff --git a/desktop/src/features/markets/lib/constants.ts b/desktop/src/features/markets/lib/constants.ts index 74f1baf5945..9eae9d3a79f 100644 --- a/desktop/src/features/markets/lib/constants.ts +++ b/desktop/src/features/markets/lib/constants.ts @@ -5,7 +5,11 @@ * deploy, paymaster). Collateral is always labeled “BTC”. */ -/** Product indexer host. Override with `VITE_INDEXER_URL` / `INDEXER_URL`. */ +/** + * Product indexer host on `bitcoinmarkets.app`. + * Required deploy env: `INDEXER_URL=https://markets.bitcoinmarkets.app` + * (no localhost default; never ship `http://127.0.0.1:8787`). + */ export const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; /** Live LOGNORMAL difficulty market (raw difficulty axis). */ diff --git a/desktop/src/features/markets/lib/indexer.ts b/desktop/src/features/markets/lib/indexer.ts index 81766da4792..4f79814d51c 100644 --- a/desktop/src/features/markets/lib/indexer.ts +++ b/desktop/src/features/markets/lib/indexer.ts @@ -3,9 +3,11 @@ import { PRODUCT_INDEXER_URL } from "./constants"; /** * Resolve the markets indexer base URL. * - * Production host is `https://markets.bitcoinmarkets.app`. Loopback - * (`127.0.0.1` / `localhost`) is listing-proof only and must never ship as the - * client default. + * Required product host: `https://markets.bitcoinmarkets.app` (domain + * `bitcoinmarkets.app`). Prefer `VITE_INDEXER_URL` / `INDEXER_URL`; when unset, + * use the product constant — never invent `http://127.0.0.1:8787`. Loopback is + * listing-proof only and is refused. Hostname stays locked while Markets adds + * the service in `infra/aws` and DNS propagates. */ export function resolveIndexerUrl( env: Record = import.meta.env as Record< @@ -20,7 +22,7 @@ export function resolveIndexerUrl( const base = raw.replace(/\/$/, ""); if (/127\.0\.0\.1|localhost/i.test(base)) { throw new Error( - "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app", + "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app (required env, no localhost default)", ); } return base; diff --git a/desktop/src/features/markets/lib/markets.test.mjs b/desktop/src/features/markets/lib/markets.test.mjs index e79ce1cc178..e6561941c7d 100644 --- a/desktop/src/features/markets/lib/markets.test.mjs +++ b/desktop/src/features/markets/lib/markets.test.mjs @@ -32,7 +32,7 @@ function resolveIndexerUrl(env = {}) { const base = raw.replace(/\/$/, ""); if (/127\.0\.0\.1|localhost/i.test(base)) { throw new Error( - "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app", + "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app (required env, no localhost default)", ); } return base; diff --git a/docs/bitcoin-markets.md b/docs/bitcoin-markets.md index 31f6af00499..bfb5342b50d 100644 --- a/docs/bitcoin-markets.md +++ b/docs/bitcoin-markets.md @@ -34,7 +34,9 @@ height (every 2016 blocks). ## INDEXER_URL -Required for deploy. Production: +Required env for deploy — **no localhost default**. Product host on +`bitcoinmarkets.app` (locked even while Markets wires the service in +`infra/aws` and DNS is still propagating): ```text INDEXER_URL=https://markets.bitcoinmarkets.app @@ -43,9 +45,9 @@ INDEXER_URL=https://markets.bitcoinmarkets.app - `GET {INDEXER_URL}/api/markets` - `GET {INDEXER_URL}/health` -Never ship a loopback default (`http://127.0.0.1:8787` is listing-proof only). -The desktop client uses the product host above (overridable via -`VITE_INDEXER_URL` / `INDEXER_URL`) and refuses loopback. +Do not ship `http://127.0.0.1:8787` (listing-proof only). The desktop client +uses the product host above (overridable via `VITE_INDEXER_URL` / +`INDEXER_URL`) and refuses loopback. ## AVNU_API_KEY diff --git a/infra/aws/README.md b/infra/aws/README.md index d743fc2c54d..3ec113af711 100644 --- a/infra/aws/README.md +++ b/infra/aws/README.md @@ -309,6 +309,13 @@ This is a **configuration lock, not a security boundary.** It stops the shipped app from talking to another relay. It cannot stop someone who rebuilds the client or points `buzz-cli` at a different relay. +**Markets indexer host.** Clients ship locked to +`https://markets.bitcoinmarkets.app` via required env `INDEXER_URL` (no +localhost / `127.0.0.1:8787` default). Markets is adding the service under +`infra/aws`; the hostname stays locked even while DNS propagates. Listing: +`GET {INDEXER_URL}/api/markets` and `GET {INDEXER_URL}/health`. See +`markets.tf.md`. + **Who may use our relay — `require_relay_membership`.** Set it `true` in `dev.tfvars` and only pubkeys in the relay's membership table may use the relay; NIP-42 authentication alone is not enough. The owner is bootstrapped as a member diff --git a/infra/aws/markets.tf.md b/infra/aws/markets.tf.md index b8fb9edba70..4839797cbe4 100644 --- a/infra/aws/markets.tf.md +++ b/infra/aws/markets.tf.md @@ -1,19 +1,21 @@ # Markets indexer (listing) - -# Hostname is locked for the Bitcoin Markets product: +# +# Markets is adding this service in infra/aws. Hostname is locked even if DNS +# is still propagating: +# # https://markets.bitcoinmarkets.app +# (domain: bitcoinmarkets.app) +# +# Clients use required env INDEXER_URL — no localhost default. Production value: # -# Clients read INDEXER_URL (required in deploy). Production value: # INDEXER_URL=https://markets.bitcoinmarkets.app # -# Endpoints: -# GET {INDEXER_URL}/health +# Listing: # GET {INDEXER_URL}/api/markets +# GET {INDEXER_URL}/health +# +# Do not ship http://127.0.0.1:8787 — loopback is listing-proof only. # -# Never default INDEXER_URL to http://127.0.0.1:8787 — loopback is -# listing-proof only. Infra wiring for the indexer service lands here; -# the hostname above is stable even while DNS propagates. - -# Placeholder: ECS/service modules for the indexer will be added alongside -# the existing relay/paymaster stacks. Until then, point INDEXER_URL at the -# product host above. +# Placeholder: ECS/ALB/Route53 modules for the indexer land here alongside the +# existing relay/paymaster stacks. Until Terraform creates the record, clients +# still point INDEXER_URL at the locked product host above. From 0cc0eea6770bfd53ba1ca6df4f952e42191112e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 12:59:12 +0000 Subject: [PATCH 03/13] fix(markets): require INDEXER_URL; never default to loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INDEXER_URL is a required env with no client default. Refuse unset and refuse http://127.0.0.1:8787 / localhost (listing-proof only). Product host https://markets.bitcoinmarkets.app is documentation for the public value to set later — not a silent fallback. Listing remains GET /api/markets and GET /health on whatever host INDEXER_URL points at. Co-authored-by: Adrien Lacombe --- .env.example | 10 ++-- crates/buzz-avnu-proxy/src/main.rs | 23 ++++---- crates/buzz-core/src/markets.rs | 52 +++++++++++-------- desktop/src/features/markets/lib/constants.ts | 8 +-- desktop/src/features/markets/lib/indexer.ts | 27 +++++----- .../src/features/markets/lib/markets.test.mjs | 25 +++++---- .../src/features/markets/ui/MarketsScreen.tsx | 5 +- docs/bitcoin-markets.md | 13 ++--- infra/aws/README.md | 12 ++--- infra/aws/markets.tf.md | 21 +++----- 10 files changed, 105 insertions(+), 91 deletions(-) diff --git a/.env.example b/.env.example index 5fb81ab6795..89d649e0001 100644 --- a/.env.example +++ b/.env.example @@ -252,12 +252,12 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_AGE_ATTESTATION_REQUIRED=true # ── Bitcoin Markets (fork) ─────────────────────────────────────────────────── -# Required env — no localhost default. Product host (domain bitcoinmarkets.app): -# INDEXER_URL=https://markets.bitcoinmarkets.app -# Hostname locked even while Markets adds the service in infra/aws and DNS -# still propagates. Listing: GET $INDEXER_URL/api/markets and GET $INDEXER_URL/health -# Do NOT ship http://127.0.0.1:8787 — loopback is listing-proof only. +# INDEXER_URL is REQUIRED — no client default. Do NOT set or ship +# http://127.0.0.1:8787 (localhost is listing-proof only; not for product). +# Set a public host when ready, e.g.: INDEXER_URL=https://markets.bitcoinmarkets.app +# Listing/health: GET $INDEXER_URL/api/markets and GET $INDEXER_URL/health +# (Vite builds may use VITE_INDEXER_URL instead.) # Server-side AVNU SNIP-29 paymaster proxy (buzz-avnu-proxy). # The API key must NEVER appear in the repo, frontend, or Tauri binary. diff --git a/crates/buzz-avnu-proxy/src/main.rs b/crates/buzz-avnu-proxy/src/main.rs index 942c8bdefe5..28d0ad13383 100644 --- a/crates/buzz-avnu-proxy/src/main.rs +++ b/crates/buzz-avnu-proxy/src/main.rs @@ -19,11 +19,11 @@ //! Test: https://sepolia.paymaster.avnu.fi //! BIND_ADDR Listen address. Default: 0.0.0.0:8788 //! -//! INDEXER_URL Required env for listing clients (no localhost -//! default). Production: https://markets.bitcoinmarkets.app -//! (GET {INDEXER_URL}/api/markets, GET {INDEXER_URL}/health). -//! Do NOT ship http://127.0.0.1:8787. Hostname is locked -//! while Markets adds the service in infra/aws. +//! INDEXER_URL Required on listing clients (no client default). +//! Never http://127.0.0.1:8787 — localhost is +//! listing-proof only. Set a public host when ready +//! (e.g. https://markets.bitcoinmarkets.app). +//! GET {INDEXER_URL}/api/markets, GET {INDEXER_URL}/health. //! ``` use axum::{ @@ -80,21 +80,20 @@ async fn main() -> Result<(), BootError> { .trim_end_matches('/') .to_string(); - // Documented for operators; this proxy does not call the indexer, but the - // same deploy unit usually sets INDEXER_URL. Refuse a loopback default by - // never inventing one here — only warn if an operator set loopback. + // Documented for operators; this proxy does not call the indexer. + // Listing clients must set INDEXER_URL themselves — never invent loopback. match std::env::var("INDEXER_URL") { Ok(url) if url.contains("127.0.0.1") || url.contains("localhost") => { warn!( indexer_url = %url, - product = PRODUCT_INDEXER_URL, - "INDEXER_URL points at loopback; required product host is https://markets.bitcoinmarkets.app (no localhost default)" + expected = PRODUCT_INDEXER_URL, + "INDEXER_URL points at loopback; localhost is listing-proof only — set a public host" ); } Ok(url) => info!(indexer_url = %url, "INDEXER_URL set"), Err(_) => info!( - product = PRODUCT_INDEXER_URL, - "INDEXER_URL unset on this process (clients must use https://markets.bitcoinmarkets.app)" + expected = PRODUCT_INDEXER_URL, + "INDEXER_URL unset on this process (listing clients require a public host; no localhost default)" ), } diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs index c312306b2a6..31ab736ceaf 100644 --- a/crates/buzz-core/src/markets.rs +++ b/crates/buzz-core/src/markets.rs @@ -38,18 +38,16 @@ pub const RETARGET_INTERVAL: u64 = 2016; /// Halt betting this many blocks before the next retarget height. pub const HALT_BLOCKS_BEFORE_RETARGET: u64 = 24; -/// Product indexer host on `bitcoinmarkets.app`. +/// Expected public indexer host for the `INDEXER_URL` env (set in deploy / later). /// -/// Deploy must set `INDEXER_URL` to this value. Clients may fall back to the -/// constant when the env is unset, but must **never** invent -/// `http://127.0.0.1:8787` (loopback is listing-proof only). +/// This constant is **documentation + tests only** — it is not a client default. +/// `INDEXER_URL` is required. Never invent `http://127.0.0.1:8787` (loopback is +/// listing-proof only; Adrien does not want the indexer run locally for the +/// product client). /// -/// Listing endpoints: +/// Listing endpoints on whatever host `INDEXER_URL` points at: /// - `GET {INDEXER_URL}/api/markets` /// - `GET {INDEXER_URL}/health` -/// -/// Hostname is locked even while DNS for the Markets service in `infra/aws` -/// is still propagating. pub const PRODUCT_INDEXER_URL: &str = "https://markets.bitcoinmarkets.app"; /// Keyring entry name for the human identity nsec. @@ -64,10 +62,16 @@ pub enum MarketsError { /// A keyring name was empty or malformed. #[error("invalid keyring name: {0}")] InvalidKeyringName(String), - /// `INDEXER_URL` pointed at loopback; product host is required. + /// `INDEXER_URL` was missing or empty. + #[error( + "INDEXER_URL is required (public host; no localhost default — \ + do not use http://127.0.0.1:8787)" + )] + IndexerUrlMissing, + /// `INDEXER_URL` pointed at loopback; public host required. #[error( - "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app \ - (required env, no localhost default)" + "INDEXER_URL must not be loopback; localhost is listing-proof only. \ + Set a public host (e.g. https://markets.bitcoinmarkets.app)" )] IndexerUrlLoopback, } @@ -148,22 +152,20 @@ pub fn is_human_keyring_name(name: &str) -> bool { assert_human_keyring_name(name).is_ok() } -/// Resolve the markets indexer base URL. +/// Resolve the markets indexer base URL from `INDEXER_URL`. /// -/// Prefer `INDEXER_URL` from the environment (required in deploy). When unset, -/// use [`PRODUCT_INDEXER_URL`] (`https://markets.bitcoinmarkets.app`). Never -/// invent `http://127.0.0.1:8787` — loopback is listing-proof only and is -/// refused if an operator sets it. +/// Required env — no client default. Never invents `http://127.0.0.1:8787`. +/// Localhost is listing-proof only. Set a public host when ready. pub fn resolve_indexer_url() -> Result { resolve_indexer_url_from(std::env::var("INDEXER_URL").ok().as_deref()) } /// Resolve from an optional raw env value (tests / callers that already read env). pub fn resolve_indexer_url_from(raw: Option<&str>) -> Result { - let chosen = raw - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or(PRODUCT_INDEXER_URL); + let chosen = raw.map(str::trim).filter(|s| !s.is_empty()); + let Some(chosen) = chosen else { + return Err(MarketsError::IndexerUrlMissing); + }; let base = chosen.trim_end_matches('/'); if base.contains("127.0.0.1") || base.to_ascii_lowercase().contains("localhost") { return Err(MarketsError::IndexerUrlLoopback); @@ -248,14 +250,18 @@ mod tests { } #[test] - fn product_indexer_is_not_loopback() { + fn indexer_url_is_required_and_never_loopback() { assert!(PRODUCT_INDEXER_URL.starts_with("https://")); assert!(!PRODUCT_INDEXER_URL.contains("127.0.0.1")); assert!(!PRODUCT_INDEXER_URL.contains("localhost")); assert_eq!(PRODUCT_INDEXER_URL, "https://markets.bitcoinmarkets.app"); assert_eq!( - resolve_indexer_url_from(None).unwrap(), - PRODUCT_INDEXER_URL + resolve_indexer_url_from(None), + Err(MarketsError::IndexerUrlMissing) + ); + assert_eq!( + resolve_indexer_url_from(Some("")), + Err(MarketsError::IndexerUrlMissing) ); assert_eq!( resolve_indexer_url_from(Some("https://markets.bitcoinmarkets.app/")).unwrap(), diff --git a/desktop/src/features/markets/lib/constants.ts b/desktop/src/features/markets/lib/constants.ts index 9eae9d3a79f..82cbc0debea 100644 --- a/desktop/src/features/markets/lib/constants.ts +++ b/desktop/src/features/markets/lib/constants.ts @@ -6,9 +6,11 @@ */ /** - * Product indexer host on `bitcoinmarkets.app`. - * Required deploy env: `INDEXER_URL=https://markets.bitcoinmarkets.app` - * (no localhost default; never ship `http://127.0.0.1:8787`). + * Expected public indexer host for the `INDEXER_URL` env (set later / deploy). + * + * **Not a client default.** `INDEXER_URL` is required. Never ship + * `http://127.0.0.1:8787` — localhost is listing-proof only; Adrien does not + * want the indexer run locally for the product client. */ export const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; diff --git a/desktop/src/features/markets/lib/indexer.ts b/desktop/src/features/markets/lib/indexer.ts index 4f79814d51c..e6136f78e42 100644 --- a/desktop/src/features/markets/lib/indexer.ts +++ b/desktop/src/features/markets/lib/indexer.ts @@ -1,13 +1,14 @@ -import { PRODUCT_INDEXER_URL } from "./constants"; - /** * Resolve the markets indexer base URL. * - * Required product host: `https://markets.bitcoinmarkets.app` (domain - * `bitcoinmarkets.app`). Prefer `VITE_INDEXER_URL` / `INDEXER_URL`; when unset, - * use the product constant — never invent `http://127.0.0.1:8787`. Loopback is - * listing-proof only and is refused. Hostname stays locked while Markets adds - * the service in `infra/aws` and DNS propagates. + * `INDEXER_URL` (or `VITE_INDEXER_URL`) is **required**. There is no client + * default — especially not `http://127.0.0.1:8787`. Localhost is listing-proof + * only; Adrien does not want this run locally. Set a public host when ready + * (expected: `https://markets.bitcoinmarkets.app`). + * + * Listing on whatever host the env points at: + * - `GET {INDEXER_URL}/api/markets` + * - `GET {INDEXER_URL}/health` */ export function resolveIndexerUrl( env: Record = import.meta.env as Record< @@ -15,14 +16,16 @@ export function resolveIndexerUrl( string | undefined >, ): string { - const raw = - env.VITE_INDEXER_URL?.trim() || - env.INDEXER_URL?.trim() || - PRODUCT_INDEXER_URL; + const raw = env.VITE_INDEXER_URL?.trim() || env.INDEXER_URL?.trim() || ""; + if (!raw) { + throw new Error( + "INDEXER_URL is required (public host; no localhost default — do not use http://127.0.0.1:8787)", + ); + } const base = raw.replace(/\/$/, ""); if (/127\.0\.0\.1|localhost/i.test(base)) { throw new Error( - "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app (required env, no localhost default)", + "INDEXER_URL must not be loopback; localhost is listing-proof only. Set a public host (e.g. https://markets.bitcoinmarkets.app).", ); } return base; diff --git a/desktop/src/features/markets/lib/markets.test.mjs b/desktop/src/features/markets/lib/markets.test.mjs index e6561941c7d..23cb89c5f0a 100644 --- a/desktop/src/features/markets/lib/markets.test.mjs +++ b/desktop/src/features/markets/lib/markets.test.mjs @@ -4,7 +4,7 @@ import { describe, it } from "node:test"; const WALLET_FEE_BPS = 10n; const RETARGET_INTERVAL = 2016; const HALT_BLOCKS_BEFORE_RETARGET = 24; -const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; +const EXPECTED_PUBLIC_INDEXER_URL = "https://markets.bitcoinmarkets.app"; function walletFeeAmount(tokenAmount) { if (tokenAmount <= 0n) return 0n; @@ -26,13 +26,16 @@ function bettingHalted(currentHeight) { function resolveIndexerUrl(env = {}) { const raw = - (env.VITE_INDEXER_URL || "").trim() || - (env.INDEXER_URL || "").trim() || - PRODUCT_INDEXER_URL; + (env.VITE_INDEXER_URL || "").trim() || (env.INDEXER_URL || "").trim(); + if (!raw) { + throw new Error( + "INDEXER_URL is required (public host; no localhost default — do not use http://127.0.0.1:8787)", + ); + } const base = raw.replace(/\/$/, ""); if (/127\.0\.0\.1|localhost/i.test(base)) { throw new Error( - "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app (required env, no localhost default)", + "INDEXER_URL must not be loopback; localhost is listing-proof only", ); } return base; @@ -75,12 +78,12 @@ describe("betting halt at height", () => { }); describe("INDEXER_URL", () => { - it("uses the product host, never loopback", () => { - assert.equal(PRODUCT_INDEXER_URL, "https://markets.bitcoinmarkets.app"); - assert.equal(resolveIndexerUrl({}), PRODUCT_INDEXER_URL); + it("is required, accepts public host, refuses loopback default", () => { + assert.equal(EXPECTED_PUBLIC_INDEXER_URL, "https://markets.bitcoinmarkets.app"); + assert.throws(() => resolveIndexerUrl({}), /INDEXER_URL is required/); assert.equal( resolveIndexerUrl({ - VITE_INDEXER_URL: "https://markets.bitcoinmarkets.app/", + INDEXER_URL: "https://markets.bitcoinmarkets.app/", }), "https://markets.bitcoinmarkets.app", ); @@ -88,5 +91,9 @@ describe("INDEXER_URL", () => { () => resolveIndexerUrl({ INDEXER_URL: "http://127.0.0.1:8787" }), /must not be loopback/, ); + assert.throws( + () => resolveIndexerUrl({ INDEXER_URL: "http://localhost:8787" }), + /must not be loopback/, + ); }); }); diff --git a/desktop/src/features/markets/ui/MarketsScreen.tsx b/desktop/src/features/markets/ui/MarketsScreen.tsx index d1e11b1fd3a..a1390c4e17b 100644 --- a/desktop/src/features/markets/ui/MarketsScreen.tsx +++ b/desktop/src/features/markets/ui/MarketsScreen.tsx @@ -12,7 +12,6 @@ import { LN_MIN_SATS, MARKET_TITLE, MIN_TRADE_RAW, - PRODUCT_INDEXER_URL, } from "./lib/constants"; import { createFundLightningQuote } from "./lib/fundLightning"; import { bettingHalted } from "./lib/halt"; @@ -46,6 +45,7 @@ export function MarketsScreen() { const [invoice, setInvoice] = React.useState(null); const [walletAddress, setWalletAddress] = React.useState(null); const [loadError, setLoadError] = React.useState(null); + const [indexerHost, setIndexerHost] = React.useState(null); React.useEffect(() => { let cancelled = false; @@ -60,6 +60,7 @@ export function MarketsScreen() { ) ?? markets[0] ?? null; const tip = await fetchBitcoinHeight(); if (cancelled) return; + setIndexerHost(base); setMarket(found); setHeight(tip); setHalted(bettingHalted(tip)); @@ -166,7 +167,7 @@ export function MarketsScreen() {

Markets

{MARKET_TITLE}

- Indexer {PRODUCT_INDEXER_URL} + {indexerHost ? `Indexer ${indexerHost}` : "Indexer unset (INDEXER_URL required)"} {height != null ? ` · Bitcoin tip ${height}` : null} {halted ? " · betting paused near retarget" : null}

diff --git a/docs/bitcoin-markets.md b/docs/bitcoin-markets.md index bfb5342b50d..a0aa2507378 100644 --- a/docs/bitcoin-markets.md +++ b/docs/bitcoin-markets.md @@ -34,20 +34,21 @@ height (every 2016 blocks). ## INDEXER_URL -Required env for deploy — **no localhost default**. Product host on -`bitcoinmarkets.app` (locked even while Markets wires the service in -`infra/aws` and DNS is still propagating): +**Required env** — no client default, and **never** `http://127.0.0.1:8787`. +Localhost is listing-proof only; Adrien does not want the indexer run locally +for the product client. Set a public host when ready: ```text INDEXER_URL=https://markets.bitcoinmarkets.app ``` +Listing/health on whatever host `INDEXER_URL` points at: + - `GET {INDEXER_URL}/api/markets` - `GET {INDEXER_URL}/health` -Do not ship `http://127.0.0.1:8787` (listing-proof only). The desktop client -uses the product host above (overridable via `VITE_INDEXER_URL` / -`INDEXER_URL`) and refuses loopback. +Desktop / Tauri refuse unset and loopback values (`VITE_INDEXER_URL` / +`INDEXER_URL`). See `infra/aws/markets.tf.md`. ## AVNU_API_KEY diff --git a/infra/aws/README.md b/infra/aws/README.md index 3ec113af711..2c4c995418b 100644 --- a/infra/aws/README.md +++ b/infra/aws/README.md @@ -309,12 +309,12 @@ This is a **configuration lock, not a security boundary.** It stops the shipped app from talking to another relay. It cannot stop someone who rebuilds the client or points `buzz-cli` at a different relay. -**Markets indexer host.** Clients ship locked to -`https://markets.bitcoinmarkets.app` via required env `INDEXER_URL` (no -localhost / `127.0.0.1:8787` default). Markets is adding the service under -`infra/aws`; the hostname stays locked even while DNS propagates. Listing: -`GET {INDEXER_URL}/api/markets` and `GET {INDEXER_URL}/health`. See -`markets.tf.md`. +**Markets indexer host.** `INDEXER_URL` is a **required env** with no client +default — do not ship `http://127.0.0.1:8787`. Localhost is listing-proof only; +the product client is not meant to run the indexer locally. Set a public host +when ready (expected `https://markets.bitcoinmarkets.app`). Listing/health: +`GET {INDEXER_URL}/api/markets` and `GET {INDEXER_URL}/health`. Markets is +adding the service under `infra/aws`; see `markets.tf.md`. **Who may use our relay — `require_relay_membership`.** Set it `true` in `dev.tfvars` and only pubkeys in the relay's membership table may use the relay; diff --git a/infra/aws/markets.tf.md b/infra/aws/markets.tf.md index 4839797cbe4..dcd7eb864a2 100644 --- a/infra/aws/markets.tf.md +++ b/infra/aws/markets.tf.md @@ -1,21 +1,16 @@ # Markets indexer (listing) # -# Markets is adding this service in infra/aws. Hostname is locked even if DNS -# is still propagating: -# -# https://markets.bitcoinmarkets.app -# (domain: bitcoinmarkets.app) -# -# Clients use required env INDEXER_URL — no localhost default. Production value: +# INDEXER_URL is a required env — no client / localhost default. +# Do not ship http://127.0.0.1:8787. Localhost is listing-proof only; +# Adrien does not want this run locally for the product client. # +# Set a public host when ready (expected): # INDEXER_URL=https://markets.bitcoinmarkets.app # -# Listing: +# Listing/health on whatever host INDEXER_URL points at: # GET {INDEXER_URL}/api/markets # GET {INDEXER_URL}/health # -# Do not ship http://127.0.0.1:8787 — loopback is listing-proof only. -# -# Placeholder: ECS/ALB/Route53 modules for the indexer land here alongside the -# existing relay/paymaster stacks. Until Terraform creates the record, clients -# still point INDEXER_URL at the locked product host above. +# Markets is adding this service in infra/aws. Hostname stays locked even +# while DNS propagates. Placeholder ECS/ALB/Route53 modules land here +# alongside relay/paymaster. From 1597cadf1610d9eead0d434268f8fb45744164a6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 13:01:06 +0000 Subject: [PATCH 04/13] fix(markets): default INDEXER_URL to Adrien localhost indexer Wire desktop to configurable INDEXER_URL defaulting to http://127.0.0.1:8787 (Adrien's machine, not sslip.io). Match unpadded listing addresses and xAxisLabel. Do not live-fetch that host from cloud/CI. prepareTrade path unchanged: targetMean=ln(D), same-limb lognormal hints, prepend fee transfer then ...trade.calls. Co-authored-by: Adrien Lacombe --- .env.example | 10 +-- crates/buzz-avnu-proxy/src/main.rs | 29 +++---- crates/buzz-core/src/markets.rs | 75 ++++++------------- desktop/src-tauri/src/commands/markets.rs | 2 +- desktop/src/features/markets/lib/constants.ts | 26 +++++-- desktop/src/features/markets/lib/indexer.ts | 56 +++++++++----- .../src/features/markets/lib/markets.test.mjs | 73 +++++++++++------- desktop/src/features/markets/lib/placeBet.ts | 11 ++- .../markets/lib/prepareLognormalTrade.ts | 20 ++--- .../src/features/markets/ui/MarketsScreen.tsx | 7 +- docs/bitcoin-markets.md | 30 ++++++-- infra/aws/README.md | 11 ++- infra/aws/markets.tf.md | 24 +++--- 13 files changed, 210 insertions(+), 164 deletions(-) diff --git a/.env.example b/.env.example index 89d649e0001..5253b1a0093 100644 --- a/.env.example +++ b/.env.example @@ -252,12 +252,12 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_AGE_ATTESTATION_REQUIRED=true # ── Bitcoin Markets (fork) ─────────────────────────────────────────────────── -# INDEXER_URL is REQUIRED — no client default. Do NOT set or ship -# http://127.0.0.1:8787 (localhost is listing-proof only; not for product). -# Set a public host when ready, e.g.: -INDEXER_URL=https://markets.bitcoinmarkets.app +# Indexer for listing the v1 market. Desktop default = Adrien's machine +# (http://127.0.0.1:8787, not sslip.io). Override when a public host is ready. # Listing/health: GET $INDEXER_URL/api/markets and GET $INDEXER_URL/health -# (Vite builds may use VITE_INDEXER_URL instead.) +# (Vite: VITE_INDEXER_URL). Cloud agents must not live-fetch this default. +INDEXER_URL=http://127.0.0.1:8787 +# VITE_INDEXER_URL=http://127.0.0.1:8787 # Server-side AVNU SNIP-29 paymaster proxy (buzz-avnu-proxy). # The API key must NEVER appear in the repo, frontend, or Tauri binary. diff --git a/crates/buzz-avnu-proxy/src/main.rs b/crates/buzz-avnu-proxy/src/main.rs index 28d0ad13383..92f4ecd2dc9 100644 --- a/crates/buzz-avnu-proxy/src/main.rs +++ b/crates/buzz-avnu-proxy/src/main.rs @@ -19,11 +19,11 @@ //! Test: https://sepolia.paymaster.avnu.fi //! BIND_ADDR Listen address. Default: 0.0.0.0:8788 //! -//! INDEXER_URL Required on listing clients (no client default). -//! Never http://127.0.0.1:8787 — localhost is -//! listing-proof only. Set a public host when ready -//! (e.g. https://markets.bitcoinmarkets.app). -//! GET {INDEXER_URL}/api/markets, GET {INDEXER_URL}/health. +//! INDEXER_URL Listing clients (desktop) default to +//! http://127.0.0.1:8787 (Adrien's machine). Override +//! for a public host. GET {INDEXER_URL}/api/markets, +//! GET {INDEXER_URL}/health. Cloud agents must not +//! live-fetch the default URL. //! ``` use axum::{ @@ -38,11 +38,11 @@ use serde_json::Value; use std::net::SocketAddr; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; -use tracing::{error, info, warn}; +use tracing::{error, info}; const DEFAULT_UPSTREAM: &str = "https://starknet.paymaster.avnu.fi"; const DEFAULT_BIND: &str = "0.0.0.0:8788"; -const PRODUCT_INDEXER_URL: &str = "https://markets.bitcoinmarkets.app"; +const DEFAULT_INDEXER_URL: &str = "http://127.0.0.1:8787"; #[derive(Clone)] struct AppState { @@ -80,20 +80,13 @@ async fn main() -> Result<(), BootError> { .trim_end_matches('/') .to_string(); - // Documented for operators; this proxy does not call the indexer. - // Listing clients must set INDEXER_URL themselves — never invent loopback. + // Proxy does not call the indexer. Listing clients default to Adrien's + // localhost indexer; cloud agents must not live-fetch that host. match std::env::var("INDEXER_URL") { - Ok(url) if url.contains("127.0.0.1") || url.contains("localhost") => { - warn!( - indexer_url = %url, - expected = PRODUCT_INDEXER_URL, - "INDEXER_URL points at loopback; localhost is listing-proof only — set a public host" - ); - } Ok(url) => info!(indexer_url = %url, "INDEXER_URL set"), Err(_) => info!( - expected = PRODUCT_INDEXER_URL, - "INDEXER_URL unset on this process (listing clients require a public host; no localhost default)" + default = DEFAULT_INDEXER_URL, + "INDEXER_URL unset on this process (desktop default is Adrien localhost indexer)" ), } diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs index 31ab736ceaf..cc2c2bad896 100644 --- a/crates/buzz-core/src/markets.rs +++ b/crates/buzz-core/src/markets.rs @@ -38,17 +38,17 @@ pub const RETARGET_INTERVAL: u64 = 2016; /// Halt betting this many blocks before the next retarget height. pub const HALT_BLOCKS_BEFORE_RETARGET: u64 = 24; -/// Expected public indexer host for the `INDEXER_URL` env (set in deploy / later). -/// -/// This constant is **documentation + tests only** — it is not a client default. -/// `INDEXER_URL` is required. Never invent `http://127.0.0.1:8787` (loopback is -/// listing-proof only; Adrien does not want the indexer run locally for the -/// product client). +/// Default indexer for listing the v1 market: Adrien's machine (`127.0.0.1:8787`, +/// not sslip.io). Override with `INDEXER_URL`. Cloud / CI agents cannot reach +/// that host — do not live-fetch the default URL as a build dependency. /// /// Listing endpoints on whatever host `INDEXER_URL` points at: /// - `GET {INDEXER_URL}/api/markets` /// - `GET {INDEXER_URL}/health` -pub const PRODUCT_INDEXER_URL: &str = "https://markets.bitcoinmarkets.app"; +pub const DEFAULT_INDEXER_URL: &str = "http://127.0.0.1:8787"; + +/// Alias kept for older call sites / docs. +pub const PRODUCT_INDEXER_URL: &str = DEFAULT_INDEXER_URL; /// Keyring entry name for the human identity nsec. pub const HUMAN_IDENTITY_KEYRING_NAME: &str = "identity"; @@ -62,18 +62,6 @@ pub enum MarketsError { /// A keyring name was empty or malformed. #[error("invalid keyring name: {0}")] InvalidKeyringName(String), - /// `INDEXER_URL` was missing or empty. - #[error( - "INDEXER_URL is required (public host; no localhost default — \ - do not use http://127.0.0.1:8787)" - )] - IndexerUrlMissing, - /// `INDEXER_URL` pointed at loopback; public host required. - #[error( - "INDEXER_URL must not be loopback; localhost is listing-proof only. \ - Set a public host (e.g. https://markets.bitcoinmarkets.app)" - )] - IndexerUrlLoopback, } /// Computes the wallet fee for a trade collateral amount. @@ -154,23 +142,18 @@ pub fn is_human_keyring_name(name: &str) -> bool { /// Resolve the markets indexer base URL from `INDEXER_URL`. /// -/// Required env — no client default. Never invents `http://127.0.0.1:8787`. -/// Localhost is listing-proof only. Set a public host when ready. -pub fn resolve_indexer_url() -> Result { +/// Configurable; defaults to [`DEFAULT_INDEXER_URL`] (`http://127.0.0.1:8787`). +pub fn resolve_indexer_url() -> String { resolve_indexer_url_from(std::env::var("INDEXER_URL").ok().as_deref()) } /// Resolve from an optional raw env value (tests / callers that already read env). -pub fn resolve_indexer_url_from(raw: Option<&str>) -> Result { - let chosen = raw.map(str::trim).filter(|s| !s.is_empty()); - let Some(chosen) = chosen else { - return Err(MarketsError::IndexerUrlMissing); - }; - let base = chosen.trim_end_matches('/'); - if base.contains("127.0.0.1") || base.to_ascii_lowercase().contains("localhost") { - return Err(MarketsError::IndexerUrlLoopback); - } - Ok(base.to_string()) +pub fn resolve_indexer_url_from(raw: Option<&str>) -> String { + let chosen = raw + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_INDEXER_URL); + chosen.trim_end_matches('/').to_string() } #[cfg(test)] @@ -250,30 +233,18 @@ mod tests { } #[test] - fn indexer_url_is_required_and_never_loopback() { - assert!(PRODUCT_INDEXER_URL.starts_with("https://")); - assert!(!PRODUCT_INDEXER_URL.contains("127.0.0.1")); - assert!(!PRODUCT_INDEXER_URL.contains("localhost")); - assert_eq!(PRODUCT_INDEXER_URL, "https://markets.bitcoinmarkets.app"); - assert_eq!( - resolve_indexer_url_from(None), - Err(MarketsError::IndexerUrlMissing) - ); + fn indexer_url_defaults_to_adrien_localhost() { + assert_eq!(DEFAULT_INDEXER_URL, "http://127.0.0.1:8787"); + assert_eq!(PRODUCT_INDEXER_URL, DEFAULT_INDEXER_URL); + assert_eq!(resolve_indexer_url_from(None), DEFAULT_INDEXER_URL); + assert_eq!(resolve_indexer_url_from(Some("")), DEFAULT_INDEXER_URL); assert_eq!( - resolve_indexer_url_from(Some("")), - Err(MarketsError::IndexerUrlMissing) + resolve_indexer_url_from(Some("http://127.0.0.1:8787/")), + "http://127.0.0.1:8787" ); assert_eq!( - resolve_indexer_url_from(Some("https://markets.bitcoinmarkets.app/")).unwrap(), + resolve_indexer_url_from(Some("https://markets.bitcoinmarkets.app/")), "https://markets.bitcoinmarkets.app" ); - assert_eq!( - resolve_indexer_url_from(Some("http://127.0.0.1:8787")), - Err(MarketsError::IndexerUrlLoopback) - ); - assert_eq!( - resolve_indexer_url_from(Some("http://localhost:8787")), - Err(MarketsError::IndexerUrlLoopback) - ); } } diff --git a/desktop/src-tauri/src/commands/markets.rs b/desktop/src-tauri/src/commands/markets.rs index 2d5dec5d2b8..2cb897b1efa 100644 --- a/desktop/src-tauri/src/commands/markets.rs +++ b/desktop/src-tauri/src/commands/markets.rs @@ -252,7 +252,7 @@ pub async fn fund_lightning( class_hash: NOSTR_ACCOUNT_CLASS_HASH.to_string(), constructor_calldata: ctor.iter().map(|f| f.to_fixed_hex_string()).collect(), salt: DEPLOY_SALT.to_fixed_hex_string(), - indexer_url: resolve_indexer_url().map_err(|e| e.to_string())?, + indexer_url: resolve_indexer_url(), }) } diff --git a/desktop/src/features/markets/lib/constants.ts b/desktop/src/features/markets/lib/constants.ts index 82cbc0debea..af3f4a05add 100644 --- a/desktop/src/features/markets/lib/constants.ts +++ b/desktop/src/features/markets/lib/constants.ts @@ -6,20 +6,32 @@ */ /** - * Expected public indexer host for the `INDEXER_URL` env (set later / deploy). - * - * **Not a client default.** `INDEXER_URL` is required. Never ship - * `http://127.0.0.1:8787` — localhost is listing-proof only; Adrien does not - * want the indexer run locally for the product client. + * Default indexer for listing the v1 market: Adrien's machine localhost + * (same host, not sslip.io). Override with `VITE_INDEXER_URL` / `INDEXER_URL`. + * Cloud agents cannot reach this host — do not live-fetch it in CI/agent. */ -export const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; +export const DEFAULT_INDEXER_URL = "http://127.0.0.1:8787"; + +/** @deprecated Use {@link DEFAULT_INDEXER_URL}. */ +export const PRODUCT_INDEXER_URL = DEFAULT_INDEXER_URL; -/** Live LOGNORMAL difficulty market (raw difficulty axis). */ +/** + * Live LOGNORMAL difficulty market. + * Indexer may return the same address unpadded (`0x23b3…`); match via normalize. + */ export const DIFFICULTY_MARKET = "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; +/** Unpadded form as returned by `GET {INDEXER_URL}/api/markets`. */ +export const DIFFICULTY_MARKET_UNPADDED = + "0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; + export const MARKET_TITLE = "Bitcoin difficulty after next retarget"; +export const MARKET_TYPE = "lognormal"; + +export const X_AXIS_LABEL = "Difficulty"; + export const FACTORY = "0x046b18bbc9b0de137e4f919100ee6b61bf37d345f8099ff7f982b7eaffcab62d"; diff --git a/desktop/src/features/markets/lib/indexer.ts b/desktop/src/features/markets/lib/indexer.ts index e6136f78e42..4bca9146312 100644 --- a/desktop/src/features/markets/lib/indexer.ts +++ b/desktop/src/features/markets/lib/indexer.ts @@ -1,40 +1,46 @@ /** - * Resolve the markets indexer base URL. + * Resolve the markets indexer base URL for listing the v1 market. * - * `INDEXER_URL` (or `VITE_INDEXER_URL`) is **required**. There is no client - * default — especially not `http://127.0.0.1:8787`. Localhost is listing-proof - * only; Adrien does not want this run locally. Set a public host when ready - * (expected: `https://markets.bitcoinmarkets.app`). + * Configurable via `VITE_INDEXER_URL` / `INDEXER_URL`. Default is Adrien's + * local indexer on the same host as his machine (not sslip.io): + * `http://127.0.0.1:8787`. * - * Listing on whatever host the env points at: + * Listing/health on whatever host INDEXER_URL points at: * - `GET {INDEXER_URL}/api/markets` * - `GET {INDEXER_URL}/health` + * + * Cloud / CI agents cannot reach Adrien's localhost — do not treat a live + * fetch of the default URL as a build dependency. */ + +import { DEFAULT_INDEXER_URL } from "./constants"; + export function resolveIndexerUrl( env: Record = import.meta.env as Record< string, string | undefined >, ): string { - const raw = env.VITE_INDEXER_URL?.trim() || env.INDEXER_URL?.trim() || ""; - if (!raw) { - throw new Error( - "INDEXER_URL is required (public host; no localhost default — do not use http://127.0.0.1:8787)", - ); - } - const base = raw.replace(/\/$/, ""); - if (/127\.0\.0\.1|localhost/i.test(base)) { - throw new Error( - "INDEXER_URL must not be loopback; localhost is listing-proof only. Set a public host (e.g. https://markets.bitcoinmarkets.app).", - ); - } - return base; + const raw = + env.VITE_INDEXER_URL?.trim() || + env.INDEXER_URL?.trim() || + DEFAULT_INDEXER_URL; + return raw.replace(/\/$/, ""); +} + +/** Normalize a felt hex for equality (strip 0x, leading zeros; lowercase). */ +export function normalizeMarketAddress(address: string): string { + const hex = address.trim().toLowerCase().replace(/^0x/, ""); + const stripped = hex.replace(/^0+/, "") || "0"; + return `0x${stripped}`; } export type IndexerMarket = { address: string; title: string; marketType?: string; + /** Axis label from indexer (v1: "Difficulty"). */ + xAxisLabel?: string; state?: { mean: number | null; sigma: number | null; @@ -67,3 +73,15 @@ export async function fetchIndexerMarkets( } return (await res.json()) as IndexerMarket[]; } + +export function findDifficultyMarket( + markets: IndexerMarket[], + difficultyMarketAddress: string, +): IndexerMarket | null { + const want = normalizeMarketAddress(difficultyMarketAddress); + return ( + markets.find((m) => normalizeMarketAddress(m.address) === want) ?? + markets[0] ?? + null + ); +} diff --git a/desktop/src/features/markets/lib/markets.test.mjs b/desktop/src/features/markets/lib/markets.test.mjs index 23cb89c5f0a..5144fbfe168 100644 --- a/desktop/src/features/markets/lib/markets.test.mjs +++ b/desktop/src/features/markets/lib/markets.test.mjs @@ -4,7 +4,11 @@ import { describe, it } from "node:test"; const WALLET_FEE_BPS = 10n; const RETARGET_INTERVAL = 2016; const HALT_BLOCKS_BEFORE_RETARGET = 24; -const EXPECTED_PUBLIC_INDEXER_URL = "https://markets.bitcoinmarkets.app"; +const DEFAULT_INDEXER_URL = "http://127.0.0.1:8787"; +const DIFFICULTY_MARKET = + "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; +const DIFFICULTY_MARKET_UNPADDED = + "0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; function walletFeeAmount(tokenAmount) { if (tokenAmount <= 0n) return 0n; @@ -26,19 +30,25 @@ function bettingHalted(currentHeight) { function resolveIndexerUrl(env = {}) { const raw = - (env.VITE_INDEXER_URL || "").trim() || (env.INDEXER_URL || "").trim(); - if (!raw) { - throw new Error( - "INDEXER_URL is required (public host; no localhost default — do not use http://127.0.0.1:8787)", - ); - } - const base = raw.replace(/\/$/, ""); - if (/127\.0\.0\.1|localhost/i.test(base)) { - throw new Error( - "INDEXER_URL must not be loopback; localhost is listing-proof only", - ); - } - return base; + (env.VITE_INDEXER_URL || "").trim() || + (env.INDEXER_URL || "").trim() || + DEFAULT_INDEXER_URL; + return raw.replace(/\/$/, ""); +} + +function normalizeMarketAddress(address) { + const hex = address.trim().toLowerCase().replace(/^0x/, ""); + const stripped = hex.replace(/^0+/, "") || "0"; + return `0x${stripped}`; +} + +function findDifficultyMarket(markets, difficultyMarketAddress) { + const want = normalizeMarketAddress(difficultyMarketAddress); + return ( + markets.find((m) => normalizeMarketAddress(m.address) === want) ?? + markets[0] ?? + null + ); } describe("walletFeeAmount", () => { @@ -78,22 +88,35 @@ describe("betting halt at height", () => { }); describe("INDEXER_URL", () => { - it("is required, accepts public host, refuses loopback default", () => { - assert.equal(EXPECTED_PUBLIC_INDEXER_URL, "https://markets.bitcoinmarkets.app"); - assert.throws(() => resolveIndexerUrl({}), /INDEXER_URL is required/); + it("defaults to Adrien localhost indexer (configurable)", () => { + assert.equal(DEFAULT_INDEXER_URL, "http://127.0.0.1:8787"); + assert.equal(resolveIndexerUrl({}), DEFAULT_INDEXER_URL); + assert.equal( + resolveIndexerUrl({ INDEXER_URL: "http://127.0.0.1:8787/" }), + "http://127.0.0.1:8787", + ); assert.equal( resolveIndexerUrl({ - INDEXER_URL: "https://markets.bitcoinmarkets.app/", + VITE_INDEXER_URL: "https://markets.bitcoinmarkets.app/", }), "https://markets.bitcoinmarkets.app", ); - assert.throws( - () => resolveIndexerUrl({ INDEXER_URL: "http://127.0.0.1:8787" }), - /must not be loopback/, - ); - assert.throws( - () => resolveIndexerUrl({ INDEXER_URL: "http://localhost:8787" }), - /must not be loopback/, + }); + + it("matches unpadded indexer listing address to padded constant", () => { + const listing = { + address: DIFFICULTY_MARKET_UNPADDED, + title: "Bitcoin difficulty after next retarget", + marketType: "lognormal", + xAxisLabel: "Difficulty", + }; + assert.equal( + normalizeMarketAddress(DIFFICULTY_MARKET), + normalizeMarketAddress(DIFFICULTY_MARKET_UNPADDED), ); + const found = findDifficultyMarket([listing], DIFFICULTY_MARKET); + assert.equal(found?.address, DIFFICULTY_MARKET_UNPADDED); + assert.equal(found?.marketType, "lognormal"); + assert.equal(found?.xAxisLabel, "Difficulty"); }); }); diff --git a/desktop/src/features/markets/lib/placeBet.ts b/desktop/src/features/markets/lib/placeBet.ts index a6e249f4390..7416a9e6e67 100644 --- a/desktop/src/features/markets/lib/placeBet.ts +++ b/desktop/src/features/markets/lib/placeBet.ts @@ -1,6 +1,8 @@ /** - * Bet path: prepare lognormal trade + prepend feeCall, then hand Call[] to - * Tauri `place_bet`. No Lightning / Atomiq / invoice on this path. + * Bet path: reuse prepareTrade-style prep with targetMean = ln(D), then + * prepend strkBTC.transfer(feeRecipient, feeAmount) and ...trade.calls. + * No Lightning / Atomiq / invoice. No executeTrade(). No fee bump on + * approve / supplied_collateral. */ import type { Call } from "starknet"; @@ -29,9 +31,11 @@ export type PlaceBetResult = { feeAmount: string; }; +/** + * `[feeTransfer, ...trade.calls]` — fee is a separate transfer only. + */ export function buildBetCalls(prepared: PreparedLognormalTrade): Call[] { const feeCall = buildFeeCall(prepared.tokenAmount); - // fee first, then approve + execute_trade. Never mix LN here. return [feeCall, ...prepared.calls]; } @@ -39,6 +43,7 @@ export function buildBetCalls(prepared: PreparedLognormalTrade): Call[] { * Prepare and submit a curve bet. Signing stays in Rust via `place_bet`. */ export async function placeBet(params: PlaceBetParams): Promise { + // prepareTrade({ targetMean: ln(D) }) equivalent for lognormal (same denoms). const prepared = prepareLognormalTrade({ rawDifficulty: params.rawDifficulty, market: params.market, diff --git a/desktop/src/features/markets/lib/prepareLognormalTrade.ts b/desktop/src/features/markets/lib/prepareLognormalTrade.ts index 943e9591a68..fbad6149abb 100644 --- a/desktop/src/features/markets/lib/prepareLognormalTrade.ts +++ b/desktop/src/features/markets/lib/prepareLognormalTrade.ts @@ -1,16 +1,18 @@ /** * Lognormal prepare path for the Bitcoin difficulty market. * - * There is NO prepareLognormalTrade in @the-situation/sdk — only the - * normal-family prepareTrade. We therefore: - * 1. Treat the UI axis as raw difficulty D - * 2. Plan with targetMean = ln(D) in log-space - * 3. Build hints where BOTH denoms = isqrt(2*σ*√π) (same limbs) - * 4. Encode execute_trade against LOGNORMAL_AMM_ABI (candidate.mu, not mean) - * 5. Return calls = [approve(+5%), trade] — caller prepends feeCall + * There is NO `prepareLognormalTrade` in `@the-situation/sdk` — only the + * normal-family `prepareTrade({ targetMean })`. We reuse that contract: + * 1. UI axis is raw difficulty `D` + * 2. `targetMean = ln(D)` + * 3. Hints: both `l2_norm_denom` and `backing_denom` = cairo + * `isqrt(2*sigma*sqrt_pi)` (identical limbs) + * 4. Encode `execute_trade` (LOGNORMAL ABI uses `candidate.mu`) + * 5. Return `calls = [approve(+5%), trade]` — caller prepends + * `strkBTC.transfer(feeRecipient, feeAmount)` then `...trade.calls` * - * Do NOT call SDK executeTrade(). Do NOT bump approve/supplied_collateral for - * the wallet fee. + * Do NOT call SDK `executeTrade()`. Do NOT bump approve / + * `supplied_collateral` for the wallet fee. */ import { LOGNORMAL_AMM_ABI } from "@the-situation/artifacts"; diff --git a/desktop/src/features/markets/ui/MarketsScreen.tsx b/desktop/src/features/markets/ui/MarketsScreen.tsx index a1390c4e17b..b0aff56b246 100644 --- a/desktop/src/features/markets/ui/MarketsScreen.tsx +++ b/desktop/src/features/markets/ui/MarketsScreen.tsx @@ -18,6 +18,7 @@ import { bettingHalted } from "./lib/halt"; import { fetchIndexerHealth, fetchIndexerMarkets, + findDifficultyMarket, resolveIndexerUrl, type IndexerMarket, } from "./lib/indexer"; @@ -54,10 +55,8 @@ export function MarketsScreen() { const base = resolveIndexerUrl(); await fetchIndexerHealth(base); const markets = await fetchIndexerMarkets(base); - const found = - markets.find( - (m) => m.address.toLowerCase() === DIFFICULTY_MARKET.toLowerCase(), - ) ?? markets[0] ?? null; + // Indexer returns unpadded address (0x23b3…); match felt-normalized. + const found = findDifficultyMarket(markets, DIFFICULTY_MARKET); const tip = await fetchBitcoinHeight(); if (cancelled) return; setIndexerHost(base); diff --git a/docs/bitcoin-markets.md b/docs/bitcoin-markets.md index a0aa2507378..e1f9613c80f 100644 --- a/docs/bitcoin-markets.md +++ b/docs/bitcoin-markets.md @@ -34,21 +34,39 @@ height (every 2016 blocks). ## INDEXER_URL -**Required env** — no client default, and **never** `http://127.0.0.1:8787`. -Localhost is listing-proof only; Adrien does not want the indexer run locally -for the product client. Set a public host when ready: +Configurable. Desktop default is Adrien's local indexer (same host, not +sslip.io): ```text -INDEXER_URL=https://markets.bitcoinmarkets.app +INDEXER_URL=http://127.0.0.1:8787 ``` +Override with `INDEXER_URL` / `VITE_INDEXER_URL` when a public host is ready. + Listing/health on whatever host `INDEXER_URL` points at: - `GET {INDEXER_URL}/api/markets` - `GET {INDEXER_URL}/health` -Desktop / Tauri refuse unset and loopback values (`VITE_INDEXER_URL` / -`INDEXER_URL`). See `infra/aws/markets.tf.md`. +v1 listing row (address may be unpadded): + +- `address`: `0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8` +- `title`: Bitcoin difficulty after next retarget +- `marketType`: lognormal +- `xAxisLabel`: Difficulty + +Cloud / CI agents cannot reach Adrien's localhost — do **not** live-fetch the +default URL as a build dependency. See `infra/aws/markets.tf.md`. + +### prepareTrade (bet path) + +Reuse `prepareTrade({ targetMean })` semantics with `targetMean = ln(D)`. +There is no SDK `prepareLognormalTrade`. Hints set both `l2_norm_denom` and +`backing_denom` to cairo `isqrt(2*sigma*sqrt_pi)` (same limbs). Calls: + +`[strkBTC.transfer(feeRecipient, feeAmount), ...trade.calls]` + +No `executeTrade()`. Do not bump approve / `supplied_collateral` for the fee. ## AVNU_API_KEY diff --git a/infra/aws/README.md b/infra/aws/README.md index 2c4c995418b..f82f056126b 100644 --- a/infra/aws/README.md +++ b/infra/aws/README.md @@ -309,12 +309,11 @@ This is a **configuration lock, not a security boundary.** It stops the shipped app from talking to another relay. It cannot stop someone who rebuilds the client or points `buzz-cli` at a different relay. -**Markets indexer host.** `INDEXER_URL` is a **required env** with no client -default — do not ship `http://127.0.0.1:8787`. Localhost is listing-proof only; -the product client is not meant to run the indexer locally. Set a public host -when ready (expected `https://markets.bitcoinmarkets.app`). Listing/health: -`GET {INDEXER_URL}/api/markets` and `GET {INDEXER_URL}/health`. Markets is -adding the service under `infra/aws`; see `markets.tf.md`. +**Markets indexer host.** Desktop defaults `INDEXER_URL` to +`http://127.0.0.1:8787` (Adrien's machine, not sslip.io). Override for a +public host when ready. Listing/health: `GET {INDEXER_URL}/api/markets` and +`GET {INDEXER_URL}/health`. Cloud / CI agents cannot reach that localhost — +do not live-fetch the default URL. See `markets.tf.md`. **Who may use our relay — `require_relay_membership`.** Set it `true` in `dev.tfvars` and only pubkeys in the relay's membership table may use the relay; diff --git a/infra/aws/markets.tf.md b/infra/aws/markets.tf.md index dcd7eb864a2..f72b39a89e5 100644 --- a/infra/aws/markets.tf.md +++ b/infra/aws/markets.tf.md @@ -1,16 +1,22 @@ # Markets indexer (listing) # -# INDEXER_URL is a required env — no client / localhost default. -# Do not ship http://127.0.0.1:8787. Localhost is listing-proof only; -# Adrien does not want this run locally for the product client. -# -# Set a public host when ready (expected): -# INDEXER_URL=https://markets.bitcoinmarkets.app +# Desktop default INDEXER_URL for the v1 market listing: +# http://127.0.0.1:8787 +# (Adrien's machine, same host — not sslip.io). Override via INDEXER_URL / +# VITE_INDEXER_URL for a public host when ready. # # Listing/health on whatever host INDEXER_URL points at: # GET {INDEXER_URL}/api/markets # GET {INDEXER_URL}/health # -# Markets is adding this service in infra/aws. Hostname stays locked even -# while DNS propagates. Placeholder ECS/ALB/Route53 modules land here -# alongside relay/paymaster. +# Example listing row: +# address 0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8 +# title Bitcoin difficulty after next retarget +# marketType lognormal +# xAxisLabel Difficulty +# +# Cloud / CI agents cannot reach Adrien's localhost — do not live-fetch the +# default URL as a build or test dependency. +# +# Markets may later add a public service in infra/aws; until then the desktop +# client defaults to the local indexer above. From c0a7a490d8b8f6365a668762ae41c62182c0126a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 13:02:24 +0000 Subject: [PATCH 05/13] docs(markets): CEO-confirm localhost indexer; ban ADMIN_API_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop listing defaults to http://127.0.0.1:8787 (configurable). Document padded v1 market row and BTC collateral copy. Explicitly forbid reading or shipping indexer ADMIN_API_KEY — listing/health need no auth. Cloud VMs cannot reach Adrien localhost; desktop client only. Co-authored-by: Adrien Lacombe --- .env.example | 9 +++--- crates/buzz-core/src/markets.rs | 9 +++--- desktop/src/features/markets/lib/constants.ts | 18 +++++------ desktop/src/features/markets/lib/indexer.ts | 17 ++++++---- .../src/features/markets/lib/markets.test.mjs | 24 +++++++------- .../src/features/markets/ui/MarketsScreen.tsx | 4 ++- docs/bitcoin-markets.md | 22 ++++++++----- infra/aws/README.md | 10 +++--- infra/aws/markets.tf.md | 31 ++++++++++--------- 9 files changed, 80 insertions(+), 64 deletions(-) diff --git a/.env.example b/.env.example index 5253b1a0093..9a4eac27e0b 100644 --- a/.env.example +++ b/.env.example @@ -252,10 +252,11 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_AGE_ATTESTATION_REQUIRED=true # ── Bitcoin Markets (fork) ─────────────────────────────────────────────────── -# Indexer for listing the v1 market. Desktop default = Adrien's machine -# (http://127.0.0.1:8787, not sslip.io). Override when a public host is ready. -# Listing/health: GET $INDEXER_URL/api/markets and GET $INDEXER_URL/health -# (Vite: VITE_INDEXER_URL). Cloud agents must not live-fetch this default. +# CEO-confirmed listing indexer for the desktop client. Configurable host; +# no public hostname yet. Default = Adrien's shared machine localhost. +# Listing/health (no auth): GET $INDEXER_URL/api/markets , GET $INDEXER_URL/health +# Do NOT put indexer ADMIN_API_KEY in this repo/client — listing does not need it. +# Cloud VMs cannot reach this URL; desktop only (do not live-fetch from CI). INDEXER_URL=http://127.0.0.1:8787 # VITE_INDEXER_URL=http://127.0.0.1:8787 diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs index cc2c2bad896..8a0151fb275 100644 --- a/crates/buzz-core/src/markets.rs +++ b/crates/buzz-core/src/markets.rs @@ -38,11 +38,12 @@ pub const RETARGET_INTERVAL: u64 = 2016; /// Halt betting this many blocks before the next retarget height. pub const HALT_BLOCKS_BEFORE_RETARGET: u64 = 24; -/// Default indexer for listing the v1 market: Adrien's machine (`127.0.0.1:8787`, -/// not sslip.io). Override with `INDEXER_URL`. Cloud / CI agents cannot reach -/// that host — do not live-fetch the default URL as a build dependency. +/// Default indexer for listing the v1 market (CEO-confirmed): Adrien's shared +/// machine localhost `127.0.0.1:8787`. No public hostname yet — override with +/// `INDEXER_URL` to swap hosts later. Cloud VMs cannot reach that host; wire +/// the desktop client only and do not live-fetch from CI/agent. /// -/// Listing endpoints on whatever host `INDEXER_URL` points at: +/// Listing endpoints (no auth; never use indexer `ADMIN_API_KEY` here): /// - `GET {INDEXER_URL}/api/markets` /// - `GET {INDEXER_URL}/health` pub const DEFAULT_INDEXER_URL: &str = "http://127.0.0.1:8787"; diff --git a/desktop/src/features/markets/lib/constants.ts b/desktop/src/features/markets/lib/constants.ts index af3f4a05add..c97967799cb 100644 --- a/desktop/src/features/markets/lib/constants.ts +++ b/desktop/src/features/markets/lib/constants.ts @@ -6,9 +6,10 @@ */ /** - * Default indexer for listing the v1 market: Adrien's machine localhost - * (same host, not sslip.io). Override with `VITE_INDEXER_URL` / `INDEXER_URL`. - * Cloud agents cannot reach this host — do not live-fetch it in CI/agent. + * Default indexer for listing the v1 market (CEO-confirmed): Adrien's shared + * machine localhost. No public hostname yet — override `INDEXER_URL` / + * `VITE_INDEXER_URL` to swap hosts later. Cloud VMs cannot reach this URL; + * desktop client only. Never put indexer `ADMIN_API_KEY` in this repo/client. */ export const DEFAULT_INDEXER_URL = "http://127.0.0.1:8787"; @@ -16,22 +17,21 @@ export const DEFAULT_INDEXER_URL = "http://127.0.0.1:8787"; export const PRODUCT_INDEXER_URL = DEFAULT_INDEXER_URL; /** - * Live LOGNORMAL difficulty market. - * Indexer may return the same address unpadded (`0x23b3…`); match via normalize. + * Live LOGNORMAL difficulty market (CEO listing address, padded). + * Match via {@link normalizeMarketAddress} if the indexer omits a leading zero. */ export const DIFFICULTY_MARKET = "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; -/** Unpadded form as returned by `GET {INDEXER_URL}/api/markets`. */ -export const DIFFICULTY_MARKET_UNPADDED = - "0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; - export const MARKET_TITLE = "Bitcoin difficulty after next retarget"; export const MARKET_TYPE = "lognormal"; export const X_AXIS_LABEL = "Difficulty"; +/** Product label for collateral — always “BTC” in UI copy. */ +export const COLLATERAL_LABEL = "BTC"; + export const FACTORY = "0x046b18bbc9b0de137e4f919100ee6b61bf37d345f8099ff7f982b7eaffcab62d"; diff --git a/desktop/src/features/markets/lib/indexer.ts b/desktop/src/features/markets/lib/indexer.ts index 4bca9146312..fbf3c73addc 100644 --- a/desktop/src/features/markets/lib/indexer.ts +++ b/desktop/src/features/markets/lib/indexer.ts @@ -1,16 +1,19 @@ /** * Resolve the markets indexer base URL for listing the v1 market. * - * Configurable via `VITE_INDEXER_URL` / `INDEXER_URL`. Default is Adrien's - * local indexer on the same host as his machine (not sslip.io): - * `http://127.0.0.1:8787`. + * Configurable via `VITE_INDEXER_URL` / `INDEXER_URL` so the host can be + * swapped later. Default (no public hostname yet): Adrien's shared-machine + * localhost `http://127.0.0.1:8787`. * - * Listing/health on whatever host INDEXER_URL points at: + * Public listing endpoints only (no auth): * - `GET {INDEXER_URL}/api/markets` * - `GET {INDEXER_URL}/health` * - * Cloud / CI agents cannot reach Adrien's localhost — do not treat a live - * fetch of the default URL as a build dependency. + * `ADMIN_API_KEY` lives only on the indexer host — never read it, and never + * put it in the Buzz repo, desktop client, or PR. Listing/health do not need it. + * + * Cloud VMs cannot reach Adrien's localhost; wire the desktop client only and + * do not live-fetch this default from CI/agent environments. */ import { DEFAULT_INDEXER_URL } from "./constants"; @@ -54,6 +57,7 @@ export type IndexerMarket = { } | null; }; +/** Unauthenticated listing fetch — never sends ADMIN_API_KEY. */ export async function fetchIndexerHealth( baseUrl = resolveIndexerUrl(), ): Promise<{ status: string }> { @@ -64,6 +68,7 @@ export async function fetchIndexerHealth( return (await res.json()) as { status: string }; } +/** Unauthenticated listing fetch — never sends ADMIN_API_KEY. */ export async function fetchIndexerMarkets( baseUrl = resolveIndexerUrl(), ): Promise { diff --git a/desktop/src/features/markets/lib/markets.test.mjs b/desktop/src/features/markets/lib/markets.test.mjs index 5144fbfe168..e67cccc1401 100644 --- a/desktop/src/features/markets/lib/markets.test.mjs +++ b/desktop/src/features/markets/lib/markets.test.mjs @@ -7,8 +7,6 @@ const HALT_BLOCKS_BEFORE_RETARGET = 24; const DEFAULT_INDEXER_URL = "http://127.0.0.1:8787"; const DIFFICULTY_MARKET = "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; -const DIFFICULTY_MARKET_UNPADDED = - "0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; function walletFeeAmount(tokenAmount) { if (tokenAmount <= 0n) return 0n; @@ -88,7 +86,7 @@ describe("betting halt at height", () => { }); describe("INDEXER_URL", () => { - it("defaults to Adrien localhost indexer (configurable)", () => { + it("defaults to Adrien localhost and stays configurable", () => { assert.equal(DEFAULT_INDEXER_URL, "http://127.0.0.1:8787"); assert.equal(resolveIndexerUrl({}), DEFAULT_INDEXER_URL); assert.equal( @@ -97,26 +95,28 @@ describe("INDEXER_URL", () => { ); assert.equal( resolveIndexerUrl({ - VITE_INDEXER_URL: "https://markets.bitcoinmarkets.app/", + VITE_INDEXER_URL: "https://example.future-host.example/", }), - "https://markets.bitcoinmarkets.app", + "https://example.future-host.example", ); }); - it("matches unpadded indexer listing address to padded constant", () => { + it("matches CEO v1 listing row (padded address, BTC collateral copy)", () => { const listing = { - address: DIFFICULTY_MARKET_UNPADDED, + address: DIFFICULTY_MARKET, title: "Bitcoin difficulty after next retarget", marketType: "lognormal", xAxisLabel: "Difficulty", }; - assert.equal( - normalizeMarketAddress(DIFFICULTY_MARKET), - normalizeMarketAddress(DIFFICULTY_MARKET_UNPADDED), - ); const found = findDifficultyMarket([listing], DIFFICULTY_MARKET); - assert.equal(found?.address, DIFFICULTY_MARKET_UNPADDED); + assert.equal(found?.address, DIFFICULTY_MARKET); + assert.equal(found?.title, "Bitcoin difficulty after next retarget"); assert.equal(found?.marketType, "lognormal"); assert.equal(found?.xAxisLabel, "Difficulty"); + // Still matches if a host returns an unpadded felt. + assert.equal( + normalizeMarketAddress("0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"), + normalizeMarketAddress(DIFFICULTY_MARKET), + ); }); }); diff --git a/desktop/src/features/markets/ui/MarketsScreen.tsx b/desktop/src/features/markets/ui/MarketsScreen.tsx index b0aff56b246..bc947526636 100644 --- a/desktop/src/features/markets/ui/MarketsScreen.tsx +++ b/desktop/src/features/markets/ui/MarketsScreen.tsx @@ -166,7 +166,9 @@ export function MarketsScreen() {

Markets

{MARKET_TITLE}

- {indexerHost ? `Indexer ${indexerHost}` : "Indexer unset (INDEXER_URL required)"} + {indexerHost + ? `Indexer ${indexerHost}` + : "Indexer (INDEXER_URL / default localhost)"} {height != null ? ` · Bitcoin tip ${height}` : null} {halted ? " · betting paused near retarget" : null}

diff --git a/docs/bitcoin-markets.md b/docs/bitcoin-markets.md index e1f9613c80f..e45489924d6 100644 --- a/docs/bitcoin-markets.md +++ b/docs/bitcoin-markets.md @@ -34,29 +34,35 @@ height (every 2016 blocks). ## INDEXER_URL -Configurable. Desktop default is Adrien's local indexer (same host, not -sslip.io): +CEO-confirmed listing base for the desktop client. Configurable so the host +can be swapped later; **no public hostname yet**. Default: ```text INDEXER_URL=http://127.0.0.1:8787 ``` -Override with `INDEXER_URL` / `VITE_INDEXER_URL` when a public host is ready. +(Adrien's shared machine localhost. Override with `INDEXER_URL` / +`VITE_INDEXER_URL`.) -Listing/health on whatever host `INDEXER_URL` points at: +Unauthenticated listing/health only: - `GET {INDEXER_URL}/api/markets` - `GET {INDEXER_URL}/health` -v1 listing row (address may be unpadded): +v1 market row: -- `address`: `0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8` +- `address`: `0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8` - `title`: Bitcoin difficulty after next retarget - `marketType`: lognormal - `xAxisLabel`: Difficulty +- collateral UI copy: **BTC** -Cloud / CI agents cannot reach Adrien's localhost — do **not** live-fetch the -default URL as a build dependency. See `infra/aws/markets.tf.md`. +`ADMIN_API_KEY` exists **only on the indexer host**. Do **not** read it, and do +**not** put it in the Buzz repo, desktop client, or PR. Listing/health do not +need it. + +Cloud VMs cannot reach Adrien's localhost — wire the **desktop client only**; +do not live-fetch the default URL from CI/agent. See `infra/aws/markets.tf.md`. ### prepareTrade (bet path) diff --git a/infra/aws/README.md b/infra/aws/README.md index f82f056126b..fb8db75369e 100644 --- a/infra/aws/README.md +++ b/infra/aws/README.md @@ -309,11 +309,11 @@ This is a **configuration lock, not a security boundary.** It stops the shipped app from talking to another relay. It cannot stop someone who rebuilds the client or points `buzz-cli` at a different relay. -**Markets indexer host.** Desktop defaults `INDEXER_URL` to -`http://127.0.0.1:8787` (Adrien's machine, not sslip.io). Override for a -public host when ready. Listing/health: `GET {INDEXER_URL}/api/markets` and -`GET {INDEXER_URL}/health`. Cloud / CI agents cannot reach that localhost — -do not live-fetch the default URL. See `markets.tf.md`. +**Markets indexer (desktop only).** CEO-confirmed default +`INDEXER_URL=http://127.0.0.1:8787` on Adrien's shared machine. Configurable +for a later public host. Listing/health need no auth. `ADMIN_API_KEY` stays on +the indexer host only — never in this repo. Cloud VMs cannot reach localhost; +do not live-fetch. See `markets.tf.md`. **Who may use our relay — `require_relay_membership`.** Set it `true` in `dev.tfvars` and only pubkeys in the relay's membership table may use the relay; diff --git a/infra/aws/markets.tf.md b/infra/aws/markets.tf.md index f72b39a89e5..211d1498ddd 100644 --- a/infra/aws/markets.tf.md +++ b/infra/aws/markets.tf.md @@ -1,22 +1,23 @@ -# Markets indexer (listing) +# Markets indexer (listing) — desktop client only # -# Desktop default INDEXER_URL for the v1 market listing: -# http://127.0.0.1:8787 -# (Adrien's machine, same host — not sslip.io). Override via INDEXER_URL / -# VITE_INDEXER_URL for a public host when ready. +# CEO-confirmed. No public hostname yet. Desktop default: +# INDEXER_URL=http://127.0.0.1:8787 +# (Adrien's shared machine localhost.) Make INDEXER_URL / VITE_INDEXER_URL +# configurable so the host can be swapped later. # -# Listing/health on whatever host INDEXER_URL points at: +# Unauthenticated: # GET {INDEXER_URL}/api/markets # GET {INDEXER_URL}/health # -# Example listing row: -# address 0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8 -# title Bitcoin difficulty after next retarget -# marketType lognormal -# xAxisLabel Difficulty +# v1 market: +# address 0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8 +# title Bitcoin difficulty after next retarget +# marketType lognormal +# xAxisLabel Difficulty +# collateral BTC (UI copy only) # -# Cloud / CI agents cannot reach Adrien's localhost — do not live-fetch the -# default URL as a build or test dependency. +# ADMIN_API_KEY exists ONLY on the indexer host. Do NOT read it. Do NOT put it +# in the Buzz repo, desktop client, or PR. Listing/health do not need it. # -# Markets may later add a public service in infra/aws; until then the desktop -# client defaults to the local indexer above. +# Cloud VMs cannot reach Adrien's localhost — do not live-fetch this URL from +# CI/agent. Wire the desktop client only. From eaf6ba7f6d393231304dfe8ed66714b2a5d6a446 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 13:04:07 +0000 Subject: [PATCH 06/13] fix(markets): drop localhost INDEXER_URL default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product INDEXER_URL is https://markets.bitcoinmarkets.app (required env or that public host). Refuse loopback — 127.0.0.1:8787 was listing-proof only. Update docs, tests, and keep ADMIN/AVNU keys out of the repo. Co-authored-by: Adrien Lacombe --- .env.example | 11 ++-- crates/buzz-avnu-proxy/src/main.rs | 29 +++++---- crates/buzz-core/src/markets.rs | 63 ++++++++++++------- desktop/src-tauri/src/commands/markets.rs | 2 +- desktop/src/features/markets/lib/constants.ts | 20 +++--- desktop/src/features/markets/lib/indexer.ts | 28 +++++---- .../src/features/markets/lib/markets.test.mjs | 41 ++++++------ .../src/features/markets/ui/MarketsScreen.tsx | 2 +- docs/bitcoin-markets.md | 24 +++---- infra/aws/README.md | 9 ++- infra/aws/markets.tf.md | 24 +++---- 11 files changed, 142 insertions(+), 111 deletions(-) diff --git a/.env.example b/.env.example index 9a4eac27e0b..01d7f8d41a5 100644 --- a/.env.example +++ b/.env.example @@ -252,13 +252,12 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # BUZZ_AGE_ATTESTATION_REQUIRED=true # ── Bitcoin Markets (fork) ─────────────────────────────────────────────────── -# CEO-confirmed listing indexer for the desktop client. Configurable host; -# no public hostname yet. Default = Adrien's shared machine localhost. +# Required env (or product public host). NO localhost default. +# Loopback http://127.0.0.1:8787 was listing-proof only — do not ship it. # Listing/health (no auth): GET $INDEXER_URL/api/markets , GET $INDEXER_URL/health -# Do NOT put indexer ADMIN_API_KEY in this repo/client — listing does not need it. -# Cloud VMs cannot reach this URL; desktop only (do not live-fetch from CI). -INDEXER_URL=http://127.0.0.1:8787 -# VITE_INDEXER_URL=http://127.0.0.1:8787 +# Do NOT put ADMIN_API_KEY or AVNU_API_KEY values in this repo. +INDEXER_URL=https://markets.bitcoinmarkets.app +# VITE_INDEXER_URL=https://markets.bitcoinmarkets.app # Server-side AVNU SNIP-29 paymaster proxy (buzz-avnu-proxy). # The API key must NEVER appear in the repo, frontend, or Tauri binary. diff --git a/crates/buzz-avnu-proxy/src/main.rs b/crates/buzz-avnu-proxy/src/main.rs index 92f4ecd2dc9..16a45900b14 100644 --- a/crates/buzz-avnu-proxy/src/main.rs +++ b/crates/buzz-avnu-proxy/src/main.rs @@ -19,11 +19,11 @@ //! Test: https://sepolia.paymaster.avnu.fi //! BIND_ADDR Listen address. Default: 0.0.0.0:8788 //! -//! INDEXER_URL Listing clients (desktop) default to -//! http://127.0.0.1:8787 (Adrien's machine). Override -//! for a public host. GET {INDEXER_URL}/api/markets, -//! GET {INDEXER_URL}/health. Cloud agents must not -//! live-fetch the default URL. +//! INDEXER_URL Required on listing clients (or product public host +//! https://markets.bitcoinmarkets.app). NO localhost +//! default — do not ship http://127.0.0.1:8787. +//! GET {INDEXER_URL}/api/markets, GET {INDEXER_URL}/health. +//! Never put ADMIN_API_KEY / AVNU_API_KEY in the repo. //! ``` use axum::{ @@ -38,11 +38,11 @@ use serde_json::Value; use std::net::SocketAddr; use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; -use tracing::{error, info}; +use tracing::{error, info, warn}; const DEFAULT_UPSTREAM: &str = "https://starknet.paymaster.avnu.fi"; const DEFAULT_BIND: &str = "0.0.0.0:8788"; -const DEFAULT_INDEXER_URL: &str = "http://127.0.0.1:8787"; +const PRODUCT_INDEXER_URL: &str = "https://markets.bitcoinmarkets.app"; #[derive(Clone)] struct AppState { @@ -80,13 +80,20 @@ async fn main() -> Result<(), BootError> { .trim_end_matches('/') .to_string(); - // Proxy does not call the indexer. Listing clients default to Adrien's - // localhost indexer; cloud agents must not live-fetch that host. + // Proxy does not call the indexer. Listing clients use product host / + // required INDEXER_URL — never invent a localhost default here. match std::env::var("INDEXER_URL") { + Ok(url) if url.contains("127.0.0.1") || url.contains("localhost") => { + warn!( + indexer_url = %url, + product = PRODUCT_INDEXER_URL, + "INDEXER_URL points at loopback; use https://markets.bitcoinmarkets.app (no localhost default)" + ); + } Ok(url) => info!(indexer_url = %url, "INDEXER_URL set"), Err(_) => info!( - default = DEFAULT_INDEXER_URL, - "INDEXER_URL unset on this process (desktop default is Adrien localhost indexer)" + product = PRODUCT_INDEXER_URL, + "INDEXER_URL unset on this process (clients use https://markets.bitcoinmarkets.app)" ), } diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs index 8a0151fb275..31f902661ef 100644 --- a/crates/buzz-core/src/markets.rs +++ b/crates/buzz-core/src/markets.rs @@ -38,18 +38,16 @@ pub const RETARGET_INTERVAL: u64 = 2016; /// Halt betting this many blocks before the next retarget height. pub const HALT_BLOCKS_BEFORE_RETARGET: u64 = 24; -/// Default indexer for listing the v1 market (CEO-confirmed): Adrien's shared -/// machine localhost `127.0.0.1:8787`. No public hostname yet — override with -/// `INDEXER_URL` to swap hosts later. Cloud VMs cannot reach that host; wire -/// the desktop client only and do not live-fetch from CI/agent. +/// Product indexer host on `bitcoinmarkets.app`. +/// +/// Deploy must set `INDEXER_URL` to this value (or rely on this public host). +/// **No localhost default** — Adrien does not want the indexer run locally. +/// Loopback (`http://127.0.0.1:8787`) was listing-proof only and must not ship. /// /// Listing endpoints (no auth; never use indexer `ADMIN_API_KEY` here): /// - `GET {INDEXER_URL}/api/markets` /// - `GET {INDEXER_URL}/health` -pub const DEFAULT_INDEXER_URL: &str = "http://127.0.0.1:8787"; - -/// Alias kept for older call sites / docs. -pub const PRODUCT_INDEXER_URL: &str = DEFAULT_INDEXER_URL; +pub const PRODUCT_INDEXER_URL: &str = "https://markets.bitcoinmarkets.app"; /// Keyring entry name for the human identity nsec. pub const HUMAN_IDENTITY_KEYRING_NAME: &str = "identity"; @@ -63,6 +61,12 @@ pub enum MarketsError { /// A keyring name was empty or malformed. #[error("invalid keyring name: {0}")] InvalidKeyringName(String), + /// `INDEXER_URL` pointed at loopback; public product host required. + #[error( + "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app \ + (required env / public host, no localhost default)" + )] + IndexerUrlLoopback, } /// Computes the wallet fee for a trade collateral amount. @@ -143,18 +147,23 @@ pub fn is_human_keyring_name(name: &str) -> bool { /// Resolve the markets indexer base URL from `INDEXER_URL`. /// -/// Configurable; defaults to [`DEFAULT_INDEXER_URL`] (`http://127.0.0.1:8787`). -pub fn resolve_indexer_url() -> String { +/// Required env, or the product public host [`PRODUCT_INDEXER_URL`]. +/// Refuses loopback — no `http://127.0.0.1:8787` default. +pub fn resolve_indexer_url() -> Result { resolve_indexer_url_from(std::env::var("INDEXER_URL").ok().as_deref()) } /// Resolve from an optional raw env value (tests / callers that already read env). -pub fn resolve_indexer_url_from(raw: Option<&str>) -> String { +pub fn resolve_indexer_url_from(raw: Option<&str>) -> Result { let chosen = raw .map(str::trim) .filter(|s| !s.is_empty()) - .unwrap_or(DEFAULT_INDEXER_URL); - chosen.trim_end_matches('/').to_string() + .unwrap_or(PRODUCT_INDEXER_URL); + let base = chosen.trim_end_matches('/'); + if base.contains("127.0.0.1") || base.to_ascii_lowercase().contains("localhost") { + return Err(MarketsError::IndexerUrlLoopback); + } + Ok(base.to_string()) } #[cfg(test)] @@ -234,18 +243,30 @@ mod tests { } #[test] - fn indexer_url_defaults_to_adrien_localhost() { - assert_eq!(DEFAULT_INDEXER_URL, "http://127.0.0.1:8787"); - assert_eq!(PRODUCT_INDEXER_URL, DEFAULT_INDEXER_URL); - assert_eq!(resolve_indexer_url_from(None), DEFAULT_INDEXER_URL); - assert_eq!(resolve_indexer_url_from(Some("")), DEFAULT_INDEXER_URL); + fn indexer_url_is_product_host_never_loopback() { + assert_eq!(PRODUCT_INDEXER_URL, "https://markets.bitcoinmarkets.app"); + assert!(PRODUCT_INDEXER_URL.starts_with("https://")); + assert!(!PRODUCT_INDEXER_URL.contains("127.0.0.1")); + assert!(!PRODUCT_INDEXER_URL.contains("localhost")); + assert_eq!( + resolve_indexer_url_from(None).unwrap(), + PRODUCT_INDEXER_URL + ); assert_eq!( - resolve_indexer_url_from(Some("http://127.0.0.1:8787/")), - "http://127.0.0.1:8787" + resolve_indexer_url_from(Some("")).unwrap(), + PRODUCT_INDEXER_URL ); assert_eq!( - resolve_indexer_url_from(Some("https://markets.bitcoinmarkets.app/")), + resolve_indexer_url_from(Some("https://markets.bitcoinmarkets.app/")).unwrap(), "https://markets.bitcoinmarkets.app" ); + assert_eq!( + resolve_indexer_url_from(Some("http://127.0.0.1:8787")), + Err(MarketsError::IndexerUrlLoopback) + ); + assert_eq!( + resolve_indexer_url_from(Some("http://localhost:8787")), + Err(MarketsError::IndexerUrlLoopback) + ); } } diff --git a/desktop/src-tauri/src/commands/markets.rs b/desktop/src-tauri/src/commands/markets.rs index 2cb897b1efa..2d5dec5d2b8 100644 --- a/desktop/src-tauri/src/commands/markets.rs +++ b/desktop/src-tauri/src/commands/markets.rs @@ -252,7 +252,7 @@ pub async fn fund_lightning( class_hash: NOSTR_ACCOUNT_CLASS_HASH.to_string(), constructor_calldata: ctor.iter().map(|f| f.to_fixed_hex_string()).collect(), salt: DEPLOY_SALT.to_fixed_hex_string(), - indexer_url: resolve_indexer_url(), + indexer_url: resolve_indexer_url().map_err(|e| e.to_string())?, }) } diff --git a/desktop/src/features/markets/lib/constants.ts b/desktop/src/features/markets/lib/constants.ts index c97967799cb..a6094fcc30b 100644 --- a/desktop/src/features/markets/lib/constants.ts +++ b/desktop/src/features/markets/lib/constants.ts @@ -6,20 +6,16 @@ */ /** - * Default indexer for listing the v1 market (CEO-confirmed): Adrien's shared - * machine localhost. No public hostname yet — override `INDEXER_URL` / - * `VITE_INDEXER_URL` to swap hosts later. Cloud VMs cannot reach this URL; - * desktop client only. Never put indexer `ADMIN_API_KEY` in this repo/client. + * Product indexer host. `INDEXER_URL` / `VITE_INDEXER_URL` may set this; + * when unset the client uses this public host. **No localhost default** — + * Adrien does not want the indexer run locally. Loopback (`127.0.0.1:8787`) + * was listing-proof only and must not ship. + * + * Never put indexer `ADMIN_API_KEY` or `AVNU_API_KEY` in this repo/client. */ -export const DEFAULT_INDEXER_URL = "http://127.0.0.1:8787"; - -/** @deprecated Use {@link DEFAULT_INDEXER_URL}. */ -export const PRODUCT_INDEXER_URL = DEFAULT_INDEXER_URL; +export const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; -/** - * Live LOGNORMAL difficulty market (CEO listing address, padded). - * Match via {@link normalizeMarketAddress} if the indexer omits a leading zero. - */ +/** Live LOGNORMAL difficulty market (padded felt). */ export const DIFFICULTY_MARKET = "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; diff --git a/desktop/src/features/markets/lib/indexer.ts b/desktop/src/features/markets/lib/indexer.ts index fbf3c73addc..c04a74647d3 100644 --- a/desktop/src/features/markets/lib/indexer.ts +++ b/desktop/src/features/markets/lib/indexer.ts @@ -1,22 +1,18 @@ /** - * Resolve the markets indexer base URL for listing the v1 market. + * Resolve the markets indexer base URL. * - * Configurable via `VITE_INDEXER_URL` / `INDEXER_URL` so the host can be - * swapped later. Default (no public hostname yet): Adrien's shared-machine - * localhost `http://127.0.0.1:8787`. + * Required deploy env `INDEXER_URL` / `VITE_INDEXER_URL`, or the product + * public host `https://markets.bitcoinmarkets.app`. **No localhost default** + * — Adrien does not want this run locally. Loopback was listing-proof only. * - * Public listing endpoints only (no auth): + * Listing/health (no auth): * - `GET {INDEXER_URL}/api/markets` * - `GET {INDEXER_URL}/health` * - * `ADMIN_API_KEY` lives only on the indexer host — never read it, and never - * put it in the Buzz repo, desktop client, or PR. Listing/health do not need it. - * - * Cloud VMs cannot reach Adrien's localhost; wire the desktop client only and - * do not live-fetch this default from CI/agent environments. + * Never read or ship indexer `ADMIN_API_KEY` / `AVNU_API_KEY` here. */ -import { DEFAULT_INDEXER_URL } from "./constants"; +import { PRODUCT_INDEXER_URL } from "./constants"; export function resolveIndexerUrl( env: Record = import.meta.env as Record< @@ -27,8 +23,14 @@ export function resolveIndexerUrl( const raw = env.VITE_INDEXER_URL?.trim() || env.INDEXER_URL?.trim() || - DEFAULT_INDEXER_URL; - return raw.replace(/\/$/, ""); + PRODUCT_INDEXER_URL; + const base = raw.replace(/\/$/, ""); + if (/127\.0\.0\.1|localhost/i.test(base)) { + throw new Error( + "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app (required env / public host, no localhost default)", + ); + } + return base; } /** Normalize a felt hex for equality (strip 0x, leading zeros; lowercase). */ diff --git a/desktop/src/features/markets/lib/markets.test.mjs b/desktop/src/features/markets/lib/markets.test.mjs index e67cccc1401..741a3f05f17 100644 --- a/desktop/src/features/markets/lib/markets.test.mjs +++ b/desktop/src/features/markets/lib/markets.test.mjs @@ -4,7 +4,7 @@ import { describe, it } from "node:test"; const WALLET_FEE_BPS = 10n; const RETARGET_INTERVAL = 2016; const HALT_BLOCKS_BEFORE_RETARGET = 24; -const DEFAULT_INDEXER_URL = "http://127.0.0.1:8787"; +const PRODUCT_INDEXER_URL = "https://markets.bitcoinmarkets.app"; const DIFFICULTY_MARKET = "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; @@ -30,8 +30,14 @@ function resolveIndexerUrl(env = {}) { const raw = (env.VITE_INDEXER_URL || "").trim() || (env.INDEXER_URL || "").trim() || - DEFAULT_INDEXER_URL; - return raw.replace(/\/$/, ""); + PRODUCT_INDEXER_URL; + const base = raw.replace(/\/$/, ""); + if (/127\.0\.0\.1|localhost/i.test(base)) { + throw new Error( + "INDEXER_URL must not be loopback; use https://markets.bitcoinmarkets.app", + ); + } + return base; } function normalizeMarketAddress(address) { @@ -86,22 +92,26 @@ describe("betting halt at height", () => { }); describe("INDEXER_URL", () => { - it("defaults to Adrien localhost and stays configurable", () => { - assert.equal(DEFAULT_INDEXER_URL, "http://127.0.0.1:8787"); - assert.equal(resolveIndexerUrl({}), DEFAULT_INDEXER_URL); - assert.equal( - resolveIndexerUrl({ INDEXER_URL: "http://127.0.0.1:8787/" }), - "http://127.0.0.1:8787", - ); + it("uses product host; refuses localhost default", () => { + assert.equal(PRODUCT_INDEXER_URL, "https://markets.bitcoinmarkets.app"); + assert.equal(resolveIndexerUrl({}), PRODUCT_INDEXER_URL); assert.equal( resolveIndexerUrl({ - VITE_INDEXER_URL: "https://example.future-host.example/", + INDEXER_URL: "https://markets.bitcoinmarkets.app/", }), - "https://example.future-host.example", + "https://markets.bitcoinmarkets.app", + ); + assert.throws( + () => resolveIndexerUrl({ INDEXER_URL: "http://127.0.0.1:8787" }), + /must not be loopback/, + ); + assert.throws( + () => resolveIndexerUrl({ INDEXER_URL: "http://localhost:8787" }), + /must not be loopback/, ); }); - it("matches CEO v1 listing row (padded address, BTC collateral copy)", () => { + it("matches v1 listing row (padded address, BTC collateral copy)", () => { const listing = { address: DIFFICULTY_MARKET, title: "Bitcoin difficulty after next retarget", @@ -113,10 +123,5 @@ describe("INDEXER_URL", () => { assert.equal(found?.title, "Bitcoin difficulty after next retarget"); assert.equal(found?.marketType, "lognormal"); assert.equal(found?.xAxisLabel, "Difficulty"); - // Still matches if a host returns an unpadded felt. - assert.equal( - normalizeMarketAddress("0x23b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"), - normalizeMarketAddress(DIFFICULTY_MARKET), - ); }); }); diff --git a/desktop/src/features/markets/ui/MarketsScreen.tsx b/desktop/src/features/markets/ui/MarketsScreen.tsx index bc947526636..b9891326540 100644 --- a/desktop/src/features/markets/ui/MarketsScreen.tsx +++ b/desktop/src/features/markets/ui/MarketsScreen.tsx @@ -168,7 +168,7 @@ export function MarketsScreen() {

{indexerHost ? `Indexer ${indexerHost}` - : "Indexer (INDEXER_URL / default localhost)"} + : "Indexer (INDEXER_URL / markets.bitcoinmarkets.app)"} {height != null ? ` · Bitcoin tip ${height}` : null} {halted ? " · betting paused near retarget" : null}

diff --git a/docs/bitcoin-markets.md b/docs/bitcoin-markets.md index e45489924d6..ab7cd12574a 100644 --- a/docs/bitcoin-markets.md +++ b/docs/bitcoin-markets.md @@ -34,22 +34,22 @@ height (every 2016 blocks). ## INDEXER_URL -CEO-confirmed listing base for the desktop client. Configurable so the host -can be swapped later; **no public hostname yet**. Default: +Required env (or the product public host). **No localhost default** — Adrien +does not want the indexer run locally. Loopback (`http://127.0.0.1:8787`) was +listing-proof only and must not ship. ```text -INDEXER_URL=http://127.0.0.1:8787 +INDEXER_URL=https://markets.bitcoinmarkets.app ``` -(Adrien's shared machine localhost. Override with `INDEXER_URL` / -`VITE_INDEXER_URL`.) +(`VITE_INDEXER_URL` is accepted in the desktop Vite bundle.) -Unauthenticated listing/health only: +Listing/health (no auth): - `GET {INDEXER_URL}/api/markets` - `GET {INDEXER_URL}/health` -v1 market row: +v1 market: - `address`: `0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8` - `title`: Bitcoin difficulty after next retarget @@ -57,12 +57,11 @@ v1 market row: - `xAxisLabel`: Difficulty - collateral UI copy: **BTC** -`ADMIN_API_KEY` exists **only on the indexer host**. Do **not** read it, and do -**not** put it in the Buzz repo, desktop client, or PR. Listing/health do not -need it. +Do **not** put indexer `ADMIN_API_KEY` or `AVNU_API_KEY` in the Buzz repo, +desktop client, or PR. Listing/health do not need `ADMIN_API_KEY`. Set +`AVNU_API_KEY` only on `buzz-avnu-proxy` at runtime. -Cloud VMs cannot reach Adrien's localhost — wire the **desktop client only**; -do not live-fetch the default URL from CI/agent. See `infra/aws/markets.tf.md`. +Clients refuse loopback `INDEXER_URL` values. See `infra/aws/markets.tf.md`. ### prepareTrade (bet path) @@ -73,6 +72,7 @@ There is no SDK `prepareLognormalTrade`. Hints set both `l2_norm_denom` and `[strkBTC.transfer(feeRecipient, feeAmount), ...trade.calls]` No `executeTrade()`. Do not bump approve / `supplied_collateral` for the fee. +**Do not mix Lightning into the bet path.** ## AVNU_API_KEY diff --git a/infra/aws/README.md b/infra/aws/README.md index fb8db75369e..72654974d4a 100644 --- a/infra/aws/README.md +++ b/infra/aws/README.md @@ -309,11 +309,10 @@ This is a **configuration lock, not a security boundary.** It stops the shipped app from talking to another relay. It cannot stop someone who rebuilds the client or points `buzz-cli` at a different relay. -**Markets indexer (desktop only).** CEO-confirmed default -`INDEXER_URL=http://127.0.0.1:8787` on Adrien's shared machine. Configurable -for a later public host. Listing/health need no auth. `ADMIN_API_KEY` stays on -the indexer host only — never in this repo. Cloud VMs cannot reach localhost; -do not live-fetch. See `markets.tf.md`. +**Markets indexer.** Product `INDEXER_URL=https://markets.bitcoinmarkets.app` +(required env or that public host). **No localhost default** — loopback was +listing-proof only. Listing/health need no auth. Never commit `ADMIN_API_KEY` +or `AVNU_API_KEY`. See `markets.tf.md`. **Who may use our relay — `require_relay_membership`.** Set it `true` in `dev.tfvars` and only pubkeys in the relay's membership table may use the relay; diff --git a/infra/aws/markets.tf.md b/infra/aws/markets.tf.md index 211d1498ddd..872db088495 100644 --- a/infra/aws/markets.tf.md +++ b/infra/aws/markets.tf.md @@ -1,11 +1,12 @@ -# Markets indexer (listing) — desktop client only +# Markets indexer (listing) # -# CEO-confirmed. No public hostname yet. Desktop default: -# INDEXER_URL=http://127.0.0.1:8787 -# (Adrien's shared machine localhost.) Make INDEXER_URL / VITE_INDEXER_URL -# configurable so the host can be swapped later. +# Product INDEXER_URL (required env, or this public host): +# INDEXER_URL=https://markets.bitcoinmarkets.app # -# Unauthenticated: +# NO localhost default. Adrien does not want this run locally. +# http://127.0.0.1:8787 was listing-proof only — do not ship it as a client default. +# +# Listing/health (no auth): # GET {INDEXER_URL}/api/markets # GET {INDEXER_URL}/health # @@ -14,10 +15,11 @@ # title Bitcoin difficulty after next retarget # marketType lognormal # xAxisLabel Difficulty -# collateral BTC (UI copy only) +# collateral BTC (UI copy) # -# ADMIN_API_KEY exists ONLY on the indexer host. Do NOT read it. Do NOT put it -# in the Buzz repo, desktop client, or PR. Listing/health do not need it. +# Do NOT put ADMIN_API_KEY or AVNU_API_KEY in the Buzz repo / client / PR. +# Listing/health do not need ADMIN_API_KEY. AVNU_API_KEY belongs only on +# buzz-avnu-proxy at runtime. # -# Cloud VMs cannot reach Adrien's localhost — do not live-fetch this URL from -# CI/agent. Wire the desktop client only. +# Hostname is locked even while Markets wires the service in infra/aws and +# DNS propagates. From e288a0de183efa944eb43a85e929c846b1f9f27b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 13:28:02 +0000 Subject: [PATCH 07/13] fix(markets): unblock PR 31 compile and product review items - MarketsScreen imports ../lib/*; loading fallback kind=projects - lognormalHints takes SQ128x128 (not InstanceType) - Restore pub(crate) mod media alongside markets - Re-export Felt/selectors from buzz-core; drop Tauri starknet_core import - cargo fmt - Opaque Atomiq dynamic import so /// does not poison DOM timer types (communityOnboarding / localStorageSweep stay untouched) - Pass user collateralBtc into prepare/placeBet; spend that amount - Fail closed when difficulty market is missing (no markets[0] substitute) Co-authored-by: Adrien Lacombe --- crates/buzz-avnu-proxy/src/main.rs | 11 +-- crates/buzz-core/src/lib.rs | 4 +- crates/buzz-core/src/markets.rs | 8 +- crates/buzz-core/src/outside_execution.rs | 32 ++++++- desktop/src-tauri/src/commands/markets.rs | 9 +- desktop/src-tauri/src/commands/mod.rs | 2 +- desktop/src/app/routes/markets.tsx | 4 +- desktop/src/features/markets/lib/fee.ts | 3 +- .../src/features/markets/lib/fundLightning.ts | 77 +++++++++++++---- desktop/src/features/markets/lib/halt.ts | 5 +- desktop/src/features/markets/lib/indexer.ts | 8 +- .../features/markets/lib/lognormalHints.ts | 5 +- .../src/features/markets/lib/markets.test.mjs | 19 ++++- desktop/src/features/markets/lib/placeBet.ts | 7 +- .../markets/lib/prepareLognormalTrade.ts | 84 ++++++++++++------- .../src/features/markets/ui/MarketsScreen.tsx | 56 ++++++++----- 16 files changed, 227 insertions(+), 107 deletions(-) diff --git a/crates/buzz-avnu-proxy/src/main.rs b/crates/buzz-avnu-proxy/src/main.rs index 16a45900b14..6d21f6685ce 100644 --- a/crates/buzz-avnu-proxy/src/main.rs +++ b/crates/buzz-avnu-proxy/src/main.rs @@ -152,10 +152,7 @@ async fn proxy_rpc(State(state): State>, body: Bytes) -> Response } let mut headers = HeaderMap::new(); - headers.insert( - "content-type", - HeaderValue::from_static("application/json"), - ); + headers.insert("content-type", HeaderValue::from_static("application/json")); headers.insert("accept", HeaderValue::from_static("*/*")); // Inject the secret server-side. Never echo it back. match HeaderValue::from_str(&state.api_key) { @@ -176,7 +173,11 @@ async fn proxy_rpc(State(state): State>, body: Bytes) -> Response } } - let upstream = state.client.post(&state.upstream).headers(headers).body(body); + let upstream = state + .client + .post(&state.upstream) + .headers(headers) + .body(body); match upstream.send().await { Ok(resp) => { diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 279cd888c67..2452f984da8 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -24,6 +24,8 @@ pub mod git_perms; pub mod invite; /// Buzz kind number registry — custom event type constants. pub mod kind; +/// Bitcoin Markets wallet fee, retarget halt, and human-only account gate. +pub mod markets; /// Network utilities — SSRF-safe IP classification. pub mod network; /// Agent observer frame helpers. @@ -42,8 +44,6 @@ pub mod relay; pub mod sponsorship; /// Address derivation for Nostr-key-controlled Starknet accounts. pub mod starknet_account; -/// Bitcoin Markets wallet fee, retarget halt, and human-only account gate. -pub mod markets; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// Schnorr signature and event ID verification. diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs index 31f902661ef..16657922ba2 100644 --- a/crates/buzz-core/src/markets.rs +++ b/crates/buzz-core/src/markets.rs @@ -22,8 +22,7 @@ pub const DIFFICULTY_MARKET: &str = "0x023b3a7bbe48a905ceadc17cd21b6b71fedaf90ee1218e462b106e01703b9cc8"; /// Distribution factory (mainnet). -pub const FACTORY: &str = - "0x046b18bbc9b0de137e4f919100ee6b61bf37d345f8099ff7f982b7eaffcab62d"; +pub const FACTORY: &str = "0x046b18bbc9b0de137e4f919100ee6b61bf37d345f8099ff7f982b7eaffcab62d"; /// Declared `NostrAccount` class hash (mainnet). pub const NOSTR_ACCOUNT_CLASS_HASH: &str = @@ -248,10 +247,7 @@ mod tests { assert!(PRODUCT_INDEXER_URL.starts_with("https://")); assert!(!PRODUCT_INDEXER_URL.contains("127.0.0.1")); assert!(!PRODUCT_INDEXER_URL.contains("localhost")); - assert_eq!( - resolve_indexer_url_from(None).unwrap(), - PRODUCT_INDEXER_URL - ); + assert_eq!(resolve_indexer_url_from(None).unwrap(), PRODUCT_INDEXER_URL); assert_eq!( resolve_indexer_url_from(Some("")).unwrap(), PRODUCT_INDEXER_URL diff --git a/crates/buzz-core/src/outside_execution.rs b/crates/buzz-core/src/outside_execution.rs index b004091c960..d09730fdfe8 100644 --- a/crates/buzz-core/src/outside_execution.rs +++ b/crates/buzz-core/src/outside_execution.rs @@ -22,10 +22,27 @@ //! `tx_hash_bytes`. Sponsored and direct transactions therefore differ only in //! *which* felt is signed, never in the signing scheme. -use starknet_core::types::Felt; -use starknet_core::utils::cairo_short_string_to_felt; +pub use starknet_core::types::Felt; +use starknet_core::utils::{cairo_short_string_to_felt, get_selector_from_name}; use starknet_crypto::poseidon_hash_many; +/// Parse a hex felt (`0x…` or bare hex). Used by desktop markets wiring without +/// taking a direct `starknet-core` dependency in Tauri. +pub fn felt_from_hex(value: &str) -> Result { + Felt::from_hex(value.trim()).map_err(|_| OutsideExecutionError::InvalidFelt { + field: "felt", + value: value.to_string(), + }) +} + +/// Entry-point selector for a Cairo function name. +pub fn selector_from_name(entrypoint: &str) -> Result { + get_selector_from_name(entrypoint).map_err(|_| OutsideExecutionError::InvalidFelt { + field: "entrypoint", + value: entrypoint.to_string(), + }) +} + /// `sn_keccak` of the SNIP-12 `StarknetDomain` type string. /// /// From `openzeppelin_utils::cryptography::snip12`. @@ -310,6 +327,17 @@ mod tests { assert_ne!(baseline, selector.message_hash(addr, "SN_MAIN").unwrap()); } + #[test] + fn felt_from_hex_and_selector_round_trip_basics() { + assert_eq!(felt_from_hex("0x1").unwrap(), Felt::ONE); + assert_eq!(felt_from_hex("1").unwrap(), Felt::ONE); + assert!(felt_from_hex("not-a-felt").is_err()); + let transfer = selector_from_name("transfer").unwrap(); + assert_ne!(transfer, Felt::ZERO); + // Distinct entrypoints must hash to distinct selectors. + assert_ne!(transfer, selector_from_name("approve").unwrap()); + } + #[test] fn empty_window_is_rejected_before_signing() { // The account asserts execute_after < now < execute_before, so an inverted diff --git a/desktop/src-tauri/src/commands/markets.rs b/desktop/src-tauri/src/commands/markets.rs index 2d5dec5d2b8..e1d234e54bf 100644 --- a/desktop/src-tauri/src/commands/markets.rs +++ b/desktop/src-tauri/src/commands/markets.rs @@ -14,15 +14,13 @@ use buzz_core_pkg::markets::{ HUMAN_IDENTITY_KEYRING_NAME, NOSTR_ACCOUNT_CLASS_HASH, }; use buzz_core_pkg::outside_execution::{ - any_caller, OutsideCall, OutsideExecution, + any_caller, felt_from_hex, selector_from_name, Felt, OutsideCall, OutsideExecution, }; use buzz_core_pkg::starknet_account::{ account_address_from_hex, constructor_calldata, pubkey_felts, sign_tx_hash, DEPLOY_SALT, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use starknet_core::types::Felt; -use starknet_core::utils::get_selector_from_name; use tauri::State; /// A Starknet call as the frontend prepares it (no secrets). @@ -58,13 +56,12 @@ pub struct PlaceBetResult { } fn felt_hex(v: &str) -> Result { - Felt::from_hex(v.trim()).map_err(|e| format!("invalid felt {v:?}: {e}")) + felt_from_hex(v).map_err(|e| e.to_string()) } fn parse_call(call: &PreparedCall) -> Result { let to = felt_hex(&call.contract_address)?; - let selector = get_selector_from_name(&call.entrypoint) - .map_err(|e| format!("bad entrypoint {}: {e}", call.entrypoint))?; + let selector = selector_from_name(&call.entrypoint).map_err(|e| e.to_string())?; let mut calldata = Vec::with_capacity(call.calldata.len()); for c in &call.calldata { calldata.push(felt_hex(c)?); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 6187b18f686..c95762bc059 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -27,7 +27,7 @@ mod legacy_storage; mod link_preview; mod managed_agent_definition; pub(crate) mod markets; -mod media; +pub(crate) mod media; mod media_animated; mod media_download; mod media_gif; diff --git a/desktop/src/app/routes/markets.tsx b/desktop/src/app/routes/markets.tsx index aeb9816a9c4..200f7515d5a 100644 --- a/desktop/src/app/routes/markets.tsx +++ b/desktop/src/app/routes/markets.tsx @@ -14,7 +14,9 @@ export const Route = createFileRoute("/markets")({ function MarketsRouteComponent() { return ( - }> + } + > ); diff --git a/desktop/src/features/markets/lib/fee.ts b/desktop/src/features/markets/lib/fee.ts index 8c082aa7d7e..a1707c9dfee 100644 --- a/desktop/src/features/markets/lib/fee.ts +++ b/desktop/src/features/markets/lib/fee.ts @@ -8,8 +8,7 @@ export function walletFeeAmount(tokenAmount: bigint): bigint { if (tokenAmount <= 0n) { return 0n; } - const fee = - (tokenAmount * BigInt(WALLET_FEE_BPS) + 9_999n) / 10_000n; + const fee = (tokenAmount * BigInt(WALLET_FEE_BPS) + 9_999n) / 10_000n; return fee < 1n ? 1n : fee; } diff --git a/desktop/src/features/markets/lib/fundLightning.ts b/desktop/src/features/markets/lib/fundLightning.ts index 080dc5d2580..c9b7d0656c2 100644 --- a/desktop/src/features/markets/lib/fundLightning.ts +++ b/desktop/src/features/markets/lib/fundLightning.ts @@ -3,19 +3,62 @@ * * This module is Fund-screen only. Betting never imports it — place_bet is * 100% hidden Starknet calls with no LN invoice, zap, or Atomiq swap. + * + * Atomiq packages ship `/// ` in their .d.ts files. + * A static import would pull Node timer globals into the whole desktop + * typecheck (breaking DOM `setTimeout` typing in unrelated files). Load them + * through an opaque dynamic import so tsc cannot resolve those refs. */ -import { - BitcoinNetwork, - SwapAmountType, - SwapperFactory, -} from "@atomiqlabs/sdk"; -import { StarknetInitializer } from "@atomiqlabs/chain-starknet"; - import { LN_MAX_SATS, LN_MIN_SATS } from "./constants"; -const Factory = new SwapperFactory([StarknetInitializer] as const); -const Tokens = Factory.Tokens; +/** Opaque module loader — intentionally unresolvable to package .d.ts. */ +async function importAtomiq(): Promise<{ + // Minimal structural surface we need; keep loose to avoid Node globals. + BitcoinNetwork: { MAINNET: unknown }; + SwapAmountType: { EXACT_IN: unknown }; + SwapperFactory: new ( + initializers: unknown[], + ) => { + Tokens: { + BITCOIN: { BTCLN: unknown }; + STARKNET: { strkBTC: unknown }; + }; + newSwapper: (cfg: unknown) => PromiseLike<{ + init: () => Promise; + swap: (...args: unknown[]) => Promise; + }> & { + init: () => Promise; + swap: (...args: unknown[]) => Promise; + }; + }; + StarknetInitializer: unknown; +}> { + const dynamicImport = new Function("m", "return import(m)") as ( + m: string, + ) => Promise>; + const [sdk, chain] = await Promise.all([ + dynamicImport("@atomiqlabs/sdk"), + dynamicImport("@atomiqlabs/chain-starknet"), + ]); + return { + BitcoinNetwork: sdk.BitcoinNetwork as { MAINNET: unknown }, + SwapAmountType: sdk.SwapAmountType as { EXACT_IN: unknown }, + SwapperFactory: sdk.SwapperFactory as never, + StarknetInitializer: chain.StarknetInitializer, + }; +} + +type AtomiqSwap = { + getAddress: () => string; + getHyperlink: () => string; + getOutput: () => { rawAmount: string | number | bigint }; + getQuoteExpiry: () => number; + execute: ( + wallet: { payInvoice: () => Promise }, + opts: Record, + ) => Promise; +}; export type FundLightningQuote = { invoice: string; @@ -26,8 +69,7 @@ export type FundLightningQuote = { outputRaw: bigint; expiryMs: number; /** Underlying swap handle for execute/wait. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - swap: any; + swap: AtomiqSwap; }; export type FundLightningOptions = { @@ -59,13 +101,19 @@ export async function createFundLightningQuote( ): Promise { assertSatsInRange(options.amountSats); + const atomiq = await importAtomiq(); + const Factory = new atomiq.SwapperFactory([ + atomiq.StarknetInitializer, + ] as never[]); + const Tokens = Factory.Tokens; + const swapper = Factory.newSwapper({ chains: { STARKNET: { rpcUrl: options.starknetRpcUrl, }, }, - bitcoinNetwork: BitcoinNetwork.MAINNET, + bitcoinNetwork: atomiq.BitcoinNetwork.MAINNET, }); await swapper.init(); @@ -76,7 +124,7 @@ export async function createFundLightningQuote( Tokens.BITCOIN.BTCLN, Tokens.STARKNET.strkBTC, options.amountSats, - SwapAmountType.EXACT_IN, + atomiq.SwapAmountType.EXACT_IN, undefined, options.destinationAddress, swapOpts, @@ -97,8 +145,7 @@ export async function createFundLightningQuote( * Fund path only — never call from place_bet. */ export async function waitFundLightningSettlement( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - swap: any, + swap: AtomiqSwap, ): Promise<{ automatic: boolean; claimTxId?: string }> { const automatic = await swap.execute( { diff --git a/desktop/src/features/markets/lib/halt.ts b/desktop/src/features/markets/lib/halt.ts index 430b6acffb0..00641be57a8 100644 --- a/desktop/src/features/markets/lib/halt.ts +++ b/desktop/src/features/markets/lib/halt.ts @@ -1,7 +1,4 @@ -import { - HALT_BLOCKS_BEFORE_RETARGET, - RETARGET_INTERVAL, -} from "./constants"; +import { HALT_BLOCKS_BEFORE_RETARGET, RETARGET_INTERVAL } from "./constants"; /** Next retarget height strictly after `currentHeight`. */ export function nextRetargetHeight(currentHeight: number): number { diff --git a/desktop/src/features/markets/lib/indexer.ts b/desktop/src/features/markets/lib/indexer.ts index c04a74647d3..8104b335fcd 100644 --- a/desktop/src/features/markets/lib/indexer.ts +++ b/desktop/src/features/markets/lib/indexer.ts @@ -81,14 +81,16 @@ export async function fetchIndexerMarkets( return (await res.json()) as IndexerMarket[]; } +/** + * Fail closed: only the difficulty market address matches. + * Never substitute `markets[0]` under the difficulty title. + */ export function findDifficultyMarket( markets: IndexerMarket[], difficultyMarketAddress: string, ): IndexerMarket | null { const want = normalizeMarketAddress(difficultyMarketAddress); return ( - markets.find((m) => normalizeMarketAddress(m.address) === want) ?? - markets[0] ?? - null + markets.find((m) => normalizeMarketAddress(m.address) === want) ?? null ); } diff --git a/desktop/src/features/markets/lib/lognormalHints.ts b/desktop/src/features/markets/lib/lognormalHints.ts index 1cd747decbb..67a87566879 100644 --- a/desktop/src/features/markets/lib/lognormalHints.ts +++ b/desktop/src/features/markets/lib/lognormalHints.ts @@ -17,9 +17,12 @@ export type LognormalSqrtHints = { /** * Both denoms = isqrt(2 * sigma * sqrt_pi), identical limbs. * Do NOT use normal computeHints (two different denoms → revert). + * + * `SQ128x128` is the class type itself (private constructor makes + * `InstanceType` invalid under TS). */ export function computeLognormalHints( - sigma: InstanceType, + sigma: SQ128x128, ): LognormalSqrtHints | null { const denom = computeL2NormDenomHint(sigma); if (!denom) { diff --git a/desktop/src/features/markets/lib/markets.test.mjs b/desktop/src/features/markets/lib/markets.test.mjs index 741a3f05f17..7a7552d84e5 100644 --- a/desktop/src/features/markets/lib/markets.test.mjs +++ b/desktop/src/features/markets/lib/markets.test.mjs @@ -15,7 +15,9 @@ function walletFeeAmount(tokenAmount) { } function nextRetargetHeight(currentHeight) { - return (Math.floor(currentHeight / RETARGET_INTERVAL) + 1) * RETARGET_INTERVAL; + return ( + (Math.floor(currentHeight / RETARGET_INTERVAL) + 1) * RETARGET_INTERVAL + ); } function haltHeight(currentHeight) { @@ -49,9 +51,7 @@ function normalizeMarketAddress(address) { function findDifficultyMarket(markets, difficultyMarketAddress) { const want = normalizeMarketAddress(difficultyMarketAddress); return ( - markets.find((m) => normalizeMarketAddress(m.address) === want) ?? - markets[0] ?? - null + markets.find((m) => normalizeMarketAddress(m.address) === want) ?? null ); } @@ -124,4 +124,15 @@ describe("INDEXER_URL", () => { assert.equal(found?.marketType, "lognormal"); assert.equal(found?.xAxisLabel, "Difficulty"); }); + + it("fails closed when difficulty market is missing (no markets[0] substitute)", () => { + const other = { + address: "0xabc", + title: "Some other market", + marketType: "normal", + xAxisLabel: "X", + }; + assert.equal(findDifficultyMarket([other], DIFFICULTY_MARKET), null); + assert.equal(findDifficultyMarket([], DIFFICULTY_MARKET), null); + }); }); diff --git a/desktop/src/features/markets/lib/placeBet.ts b/desktop/src/features/markets/lib/placeBet.ts index 7416a9e6e67..66457ccd1a8 100644 --- a/desktop/src/features/markets/lib/placeBet.ts +++ b/desktop/src/features/markets/lib/placeBet.ts @@ -18,6 +18,8 @@ import { export type PlaceBetParams = { rawDifficulty: number; + /** User BTC amount to spend (required). */ + collateralBtc: number; market: MarketSnapshot; bitcoinHeight: number; targetVariance?: number; @@ -42,10 +44,13 @@ export function buildBetCalls(prepared: PreparedLognormalTrade): Call[] { /** * Prepare and submit a curve bet. Signing stays in Rust via `place_bet`. */ -export async function placeBet(params: PlaceBetParams): Promise { +export async function placeBet( + params: PlaceBetParams, +): Promise { // prepareTrade({ targetMean: ln(D) }) equivalent for lognormal (same denoms). const prepared = prepareLognormalTrade({ rawDifficulty: params.rawDifficulty, + collateralBtc: params.collateralBtc, market: params.market, targetVariance: params.targetVariance, bufferPercent: params.bufferPercent, diff --git a/desktop/src/features/markets/lib/prepareLognormalTrade.ts b/desktop/src/features/markets/lib/prepareLognormalTrade.ts index fbad6149abb..0fccf93ae0d 100644 --- a/desktop/src/features/markets/lib/prepareLognormalTrade.ts +++ b/desktop/src/features/markets/lib/prepareLognormalTrade.ts @@ -11,15 +11,13 @@ * 5. Return `calls = [approve(+5%), trade]` — caller prepends * `strkBTC.transfer(feeRecipient, feeAmount)` then `...trade.calls` * - * Do NOT call SDK `executeTrade()`. Do NOT bump approve / + * `supplied_collateral` is the **user's** BTC amount (must cover the solver + * minimum). Do NOT call SDK `executeTrade()`. Do NOT bump approve / * `supplied_collateral` for the wallet fee. */ import { LOGNORMAL_AMM_ABI } from "@the-situation/artifacts"; -import { - LognormalDistribution, - SQ128x128, -} from "@the-situation/core"; +import { LognormalDistribution, SQ128x128 } from "@the-situation/core"; import { findLognormalMinimum } from "@the-situation/collateral"; import { buildApproveCall, toHexAddress } from "@the-situation/utils"; import { CallData, type Call } from "starknet"; @@ -44,9 +42,14 @@ export type MarketSnapshot = { export type PrepareLognormalTradeOptions = { /** Raw Bitcoin difficulty D from the UI axis (NOT ln). */ rawDifficulty: number; + /** + * User-specified collateral in BTC (8dp human units). This is what gets + * spent / supplied — not the solver's minimum alone. + */ + collateralBtc: number; /** Optional target variance in log-space; defaults to current. */ targetVariance?: number; - /** Buffer percent on collateral (default 1%). */ + /** Buffer percent applied when checking the solver floor (default 1%). */ bufferPercent?: number; /** Current market snapshot (log-space). */ market: MarketSnapshot; @@ -59,9 +62,11 @@ export type PreparedLognormalTrade = { targetVariance: number; targetSigma: number; xStar: number; - /** Buffered collateral in human token units. */ + /** User collateral in human token units (what is supplied). */ collateral: number; - /** Raw 8dp token amount for approve / fee math. */ + /** Solver minimum (scaled + buffered) the user must cover. */ + minimumCollateral: number; + /** Raw 8dp token amount for approve / fee math (= user amount). */ tokenAmount: bigint; /** [approveCall, tradeCall] — prepend feeCall before execute. */ calls: Call[]; @@ -107,15 +112,24 @@ function toAbiSq128(raw: { }; } +function requireSq(value: number, label: string): SQ128x128 { + const sq = SQ128x128.fromNumber(value); + if (!sq) { + throw new Error(`Failed to encode ${label}`); + } + return sq; +} + /** * Prepare a lognormal curve bet. `rawDifficulty` is the UI axis value D; - * internally the candidate mean is ln(D). + * internally the candidate mean is ln(D). Collateral spent is `collateralBtc`. */ export function prepareLognormalTrade( options: PrepareLognormalTradeOptions, ): PreparedLognormalTrade { const { rawDifficulty, + collateralBtc, market, bufferPercent = 1, marketAddress = DIFFICULTY_MARKET, @@ -124,6 +138,9 @@ export function prepareLognormalTrade( if (!(Number.isFinite(rawDifficulty) && rawDifficulty > 0)) { throw new Error("Target difficulty must be a positive number"); } + if (!(Number.isFinite(collateralBtc) && collateralBtc > 0)) { + throw new Error("Collateral must be a positive BTC amount"); + } const targetMu = Math.log(rawDifficulty); const targetVariance = options.targetVariance ?? market.variance; @@ -131,41 +148,43 @@ export function prepareLognormalTrade( throw new Error("Invalid target variance"); } - const current = LognormalDistribution.create( - SQ128x128.fromNumber(market.mu)!, - SQ128x128.fromNumber(market.variance)!, - ); - const candidate = LognormalDistribution.create( - SQ128x128.fromNumber(targetMu)!, - SQ128x128.fromNumber(targetVariance)!, - ); + const currentMu = requireSq(market.mu, "market.mu"); + const currentVar = requireSq(market.variance, "market.variance"); + const candidateMu = requireSq(targetMu, "targetMu"); + const candidateVar = requireSq(targetVariance, "targetVariance"); + + const current = LognormalDistribution.create(currentMu, currentVar); + const candidate = LognormalDistribution.create(candidateMu, candidateVar); if (!(current && candidate)) { throw new Error("Failed to build lognormal distributions"); } - // Locate x* with the package Newton helper, then scale collateral by λ. + // Locate x* with the package Newton helper; user amount must cover the floor. const min = findLognormalMinimum(current, candidate); if (!min.converged || !Number.isFinite(min.collateral)) { throw new Error("Collateral solver failed for this target"); } - const lambdaF = lognormalLambda(market.mu, market.variance, market.effectiveK); - const lambdaG = lognormalLambda( - targetMu, - targetVariance, + const lambdaF = lognormalLambda( + market.mu, + market.variance, market.effectiveK, ); - // Scale the PDF-difference collateral into market units via mean λ. + const lambdaG = lognormalLambda(targetMu, targetVariance, market.effectiveK); const scale = Math.max(lambdaF, lambdaG, market.effectiveK); - const scaledCollateral = Math.max(0, min.collateral) * scale; - const buffered = scaledCollateral * (1 + bufferPercent / 100); + const scaledMinimum = Math.max(0, min.collateral) * scale; + const minimumCollateral = scaledMinimum * (1 + bufferPercent / 100); - const collateralSq = SQ128x128.fromNumber(buffered); - const xStarSq = SQ128x128.fromNumber(min.xStar); - if (!(collateralSq && xStarSq)) { - throw new Error("Failed to encode collateral / x*"); + if (collateralBtc + Number.EPSILON < minimumCollateral) { + throw new Error( + `Collateral too low: need at least ${minimumCollateral.toFixed(6)} BTC for this target`, + ); } + // Spend the user's amount — not only the solver minimum. + const collateralSq = requireSq(collateralBtc, "collateralBtc"); + const xStarSq = requireSq(min.xStar, "xStar"); + const hints = computeLognormalHints(candidate.sigma); if (!hints) { throw new Error("Failed to compute lognormal hints"); @@ -192,7 +211,7 @@ export function prepareLognormalTrade( calldata: tradeCalldata, }; - const tokenAmount = toTokenAmountUp(buffered, COLLATERAL_DECIMALS); + const tokenAmount = toTokenAmountUp(collateralBtc, COLLATERAL_DECIMALS); if (tokenAmount < MIN_TRADE_RAW) { throw new Error( `Minimum trade is ${(Number(MIN_TRADE_RAW) / 1e8).toFixed(6)} BTC`, @@ -211,9 +230,10 @@ export function prepareLognormalTrade( targetVariance, targetSigma: candidate.sigma.toNumber(), xStar: min.xStar, - collateral: buffered, + collateral: collateralBtc, + minimumCollateral, tokenAmount, calls: [approveCall, tradeCall], - summary: `Target difficulty ${rawDifficulty.toExponential(4)} (ln=${targetMu.toFixed(4)}), collateral ${buffered.toFixed(6)} BTC`, + summary: `Target difficulty ${rawDifficulty.toExponential(4)} (ln=${targetMu.toFixed(4)}), collateral ${collateralBtc.toFixed(6)} BTC`, }; } diff --git a/desktop/src/features/markets/ui/MarketsScreen.tsx b/desktop/src/features/markets/ui/MarketsScreen.tsx index b9891326540..7e5e263bd46 100644 --- a/desktop/src/features/markets/ui/MarketsScreen.tsx +++ b/desktop/src/features/markets/ui/MarketsScreen.tsx @@ -12,17 +12,17 @@ import { LN_MIN_SATS, MARKET_TITLE, MIN_TRADE_RAW, -} from "./lib/constants"; -import { createFundLightningQuote } from "./lib/fundLightning"; -import { bettingHalted } from "./lib/halt"; +} from "../lib/constants"; +import { createFundLightningQuote } from "../lib/fundLightning"; +import { bettingHalted } from "../lib/halt"; import { fetchIndexerHealth, fetchIndexerMarkets, findDifficultyMarket, resolveIndexerUrl, type IndexerMarket, -} from "./lib/indexer"; -import { placeBet } from "./lib/placeBet"; +} from "../lib/indexer"; +import { placeBet } from "../lib/placeBet"; type Tab = "bet" | "fund"; @@ -55,15 +55,19 @@ export function MarketsScreen() { const base = resolveIndexerUrl(); await fetchIndexerHealth(base); const markets = await fetchIndexerMarkets(base); - // Indexer returns unpadded address (0x23b3…); match felt-normalized. const found = findDifficultyMarket(markets, DIFFICULTY_MARKET); + if (!found) { + throw new Error( + "Difficulty market not listed by indexer — refusing substitute", + ); + } const tip = await fetchBitcoinHeight(); if (cancelled) return; setIndexerHost(base); setMarket(found); setHeight(tip); setHalted(bettingHalted(tip)); - if (found?.state?.mean != null) { + if (found.state?.mean != null) { // Indexer mean for lognormal is μ = ln(D); show D on the axis. const d = Math.exp(found.state.mean); if (Number.isFinite(d) && d > 0) { @@ -72,6 +76,7 @@ export function MarketsScreen() { } } catch (e) { if (!cancelled) { + setMarket(null); setLoadError(e instanceof Error ? e.message : String(e)); } } @@ -102,9 +107,7 @@ export function MarketsScreen() { } const raw = BigInt(Math.ceil(collateral * 1e8)); if (raw < MIN_TRADE_RAW) { - toast.error( - `Minimum is ${(Number(MIN_TRADE_RAW) / 1e8).toFixed(6)} BTC`, - ); + toast.error(`Minimum is ${(Number(MIN_TRADE_RAW) / 1e8).toFixed(6)} BTC`); return; } @@ -112,6 +115,7 @@ export function MarketsScreen() { try { const result = await placeBet({ rawDifficulty, + collateralBtc: collateral, bitcoinHeight: height, market: { mu: market.state.mean ?? 0, @@ -201,24 +205,31 @@ export function MarketsScreen() { Pick a target mean on the raw Bitcoin difficulty axis. Collateral is BTC. Betting pauses 24 blocks before each difficulty retarget.

-