From 7e7393e4f8081c8cfe42bfb01b10f39ef2db966c Mon Sep 17 00:00:00 2001 From: vsecoder Date: Fri, 17 Apr 2026 14:16:44 +0300 Subject: [PATCH] chore: initial commit for v0.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract --- .dockerignore | 34 + .gitignore | 58 + CHANGELOG.md | 181 + CONTEXT.md | 229 + Dockerfile | 63 + Makefile | 94 + README.md | 310 + blockchain/block.go | 139 + blockchain/chain.go | 2589 ++++ blockchain/chain_test.go | 796 ++ blockchain/equivocation.go | 101 + blockchain/index.go | 562 + blockchain/native.go | 238 + blockchain/native_username.go | 371 + blockchain/schema_migrations.go | 197 + blockchain/types.go | 509 + client-app/.gitignore | 0 client-app/README.md | 93 + client-app/app.json | 36 + client-app/app/(app)/_layout.tsx | 127 + client-app/app/(app)/chats/[id].tsx | 413 + client-app/app/(app)/chats/_layout.tsx | 7 + client-app/app/(app)/chats/index.tsx | 337 + client-app/app/(app)/new-contact.tsx | 332 + client-app/app/(app)/requests.tsx | 236 + client-app/app/(app)/settings.tsx | 732 ++ client-app/app/(app)/wallet.tsx | 596 + client-app/app/(auth)/create.tsx | 82 + client-app/app/(auth)/created.tsx | 118 + client-app/app/(auth)/import.tsx | 301 + client-app/app/_layout.tsx | 40 + client-app/app/index.tsx | 220 + client-app/babel.config.js | 12 + client-app/components/ui/Avatar.tsx | 37 + client-app/components/ui/Badge.tsx | 24 + client-app/components/ui/Button.tsx | 76 + client-app/components/ui/Card.tsx | 16 + client-app/components/ui/Input.tsx | 34 + client-app/components/ui/Separator.tsx | 7 + client-app/components/ui/index.ts | 6 + client-app/global.css | 3 + client-app/hooks/useBalance.ts | 94 + client-app/hooks/useContacts.ts | 80 + client-app/hooks/useMessages.ts | 123 + client-app/hooks/useWellKnownContracts.ts | 61 + client-app/lib/api.ts | 701 ++ client-app/lib/crypto.ts | 156 + client-app/lib/storage.ts | 101 + client-app/lib/store.ts | 103 + client-app/lib/types.ts | 86 + client-app/lib/utils.ts | 35 + client-app/lib/ws.ts | 401 + client-app/metro.config.js | 6 + client-app/nativewind-env.d.ts | 1 + client-app/package-lock.json | 10317 ++++++++++++++++ client-app/package.json | 51 + client-app/tailwind.config.js | 28 + client-app/tsconfig.json | 9 + cmd/client/main.go | 1564 +++ cmd/loadtest/main.go | 402 + cmd/node/main.go | 1578 +++ cmd/peerid/main.go | 65 + cmd/wallet/main.go | 240 + consensus/pbft.go | 883 ++ consensus/pbft_test.go | 438 + contracts/auction/auction.wasm | Bin 0 -> 2150 bytes contracts/auction/auction_abi.json | 45 + contracts/auction/gen/main.go | 784 ++ contracts/counter/counter.wasm | Bin 0 -> 492 bytes contracts/counter/counter.wat | 102 + contracts/counter/counter_abi.json | 1 + contracts/counter/gen/main.go | 331 + contracts/counter/main.go | 137 + contracts/escrow/escrow.wasm | Bin 0 -> 2677 bytes contracts/escrow/escrow_abi.json | 57 + contracts/escrow/gen/main.go | 818 ++ contracts/governance/gen/main.go | 538 + contracts/governance/governance.wasm | Bin 0 -> 1390 bytes contracts/governance/governance_abi.json | 55 + contracts/hello_go/hello_go_abi.json | 38 + contracts/hello_go/main.go | 104 + contracts/name_registry/gen/main.go | 458 + contracts/name_registry/name_registry.wasm | Bin 0 -> 908 bytes contracts/name_registry/name_registry.wat | 301 + .../name_registry/name_registry_abi.json | 6 + contracts/sdk/dchain.go | 210 + contracts/sdk/dchain_stub.go | 53 + contracts/username_registry/gen/main.go | 678 + .../username_registry/username_registry.wasm | Bin 0 -> 1719 bytes .../username_registry_abi.json | 50 + deploy/UPDATE_STRATEGY.md | 339 + deploy/prod/Dockerfile.slim | 63 + deploy/prod/README.md | 163 + deploy/prod/caddy/Caddyfile | 88 + deploy/prod/docker-compose.yml | 175 + deploy/prod/node.env.example | 36 + deploy/prod/prometheus.yml | 17 + deploy/single/Caddyfile | 46 + deploy/single/README.md | 387 + deploy/single/docker-compose.yml | 129 + deploy/single/node.env.example | 119 + deploy/single/prometheus.yml | 18 + deploy/single/systemd/README.md | 57 + deploy/single/systemd/dchain-update.service | 35 + deploy/single/systemd/dchain-update.timer | 24 + deploy/single/update.sh | 166 + docker-compose.yml | 270 + docs/api/README.md | 45 + docs/api/chain.md | 314 + docs/api/contracts.md | 285 + docs/api/relay.md | 246 + docs/architecture.md | 207 + docs/cli/README.md | 411 + docs/contracts/README.md | 59 + docs/contracts/auction.md | 213 + docs/contracts/escrow.md | 267 + docs/contracts/governance.md | 203 + docs/contracts/username_registry.md | 133 + docs/development/README.md | 103 + docs/development/binary-wasm.md | 249 + docs/development/gas-model.md | 193 + docs/development/host-functions.md | 274 + docs/development/inter-contract.md | 129 + docs/development/tinygo.md | 287 + docs/node/README.md | 219 + docs/node/governance.md | 118 + docs/node/multi-server.md | 291 + docs/quickstart.md | 177 + economy/rewards.go | 71 + go.mod | 126 + go.sum | 615 + identity/identity.go | 245 + identity/identity_test.go | 257 + node/api_chain_v2.go | 367 + node/api_channels.go | 102 + node/api_common.go | 181 + node/api_contract.go | 199 + node/api_explorer.go | 553 + node/api_guards.go | 299 + node/api_onboarding.go | 162 + node/api_relay.go | 320 + node/api_routes.go | 270 + node/api_tokens.go | 183 + node/api_update_check.go | 201 + node/api_well_known.go | 116 + node/api_well_known_version.go | 93 + node/events.go | 119 + node/explorer/address.html | 224 + node/explorer/address.js | 414 + node/explorer/app.js | 484 + node/explorer/common.js | 197 + node/explorer/contract.html | 222 + node/explorer/contract.js | 295 + node/explorer/index.html | 166 + node/explorer/node.html | 146 + node/explorer/node.js | 110 + node/explorer/relays.html | 78 + node/explorer/relays.js | 68 + node/explorer/style.css | 1704 +++ node/explorer/token.html | 89 + node/explorer/token.js | 180 + node/explorer/tokens.html | 100 + node/explorer/tokens.js | 121 + node/explorer/tx.html | 186 + node/explorer/tx.js | 334 + node/explorer/validators.html | 81 + node/explorer/validators.js | 58 + node/explorer_assets.go | 166 + node/metrics.go | 232 + node/sse.go | 186 + node/stats.go | 316 + node/swagger/index.html | 23 + node/swagger/openapi.json | 727 ++ node/swagger_assets.go | 32 + node/version/version.go | 70 + node/ws.go | 696 ++ p2p/host.go | 469 + p2p/sync.go | 168 + p2p/version_gossip.go | 220 + relay/envelope.go | 139 + relay/envelope_test.go | 191 + relay/keypair.go | 54 + relay/mailbox.go | 265 + relay/router.go | 227 + scripts/deploy_contracts.sh | 182 + testdata/README.md | 28 + testdata/node1.json | 6 + testdata/node2.json | 6 + testdata/node3.json | 6 + vm/abi.go | 71 + vm/gas.go | 111 + vm/host.go | 382 + vm/instrument.go | 949 ++ vm/vm.go | 184 + vm/vm_test.go | 684 + wallet/wallet.go | 221 + 196 files changed, 55947 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTEXT.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 blockchain/block.go create mode 100644 blockchain/chain.go create mode 100644 blockchain/chain_test.go create mode 100644 blockchain/equivocation.go create mode 100644 blockchain/index.go create mode 100644 blockchain/native.go create mode 100644 blockchain/native_username.go create mode 100644 blockchain/schema_migrations.go create mode 100644 blockchain/types.go create mode 100644 client-app/.gitignore create mode 100644 client-app/README.md create mode 100644 client-app/app.json create mode 100644 client-app/app/(app)/_layout.tsx create mode 100644 client-app/app/(app)/chats/[id].tsx create mode 100644 client-app/app/(app)/chats/_layout.tsx create mode 100644 client-app/app/(app)/chats/index.tsx create mode 100644 client-app/app/(app)/new-contact.tsx create mode 100644 client-app/app/(app)/requests.tsx create mode 100644 client-app/app/(app)/settings.tsx create mode 100644 client-app/app/(app)/wallet.tsx create mode 100644 client-app/app/(auth)/create.tsx create mode 100644 client-app/app/(auth)/created.tsx create mode 100644 client-app/app/(auth)/import.tsx create mode 100644 client-app/app/_layout.tsx create mode 100644 client-app/app/index.tsx create mode 100644 client-app/babel.config.js create mode 100644 client-app/components/ui/Avatar.tsx create mode 100644 client-app/components/ui/Badge.tsx create mode 100644 client-app/components/ui/Button.tsx create mode 100644 client-app/components/ui/Card.tsx create mode 100644 client-app/components/ui/Input.tsx create mode 100644 client-app/components/ui/Separator.tsx create mode 100644 client-app/components/ui/index.ts create mode 100644 client-app/global.css create mode 100644 client-app/hooks/useBalance.ts create mode 100644 client-app/hooks/useContacts.ts create mode 100644 client-app/hooks/useMessages.ts create mode 100644 client-app/hooks/useWellKnownContracts.ts create mode 100644 client-app/lib/api.ts create mode 100644 client-app/lib/crypto.ts create mode 100644 client-app/lib/storage.ts create mode 100644 client-app/lib/store.ts create mode 100644 client-app/lib/types.ts create mode 100644 client-app/lib/utils.ts create mode 100644 client-app/lib/ws.ts create mode 100644 client-app/metro.config.js create mode 100644 client-app/nativewind-env.d.ts create mode 100644 client-app/package-lock.json create mode 100644 client-app/package.json create mode 100644 client-app/tailwind.config.js create mode 100644 client-app/tsconfig.json create mode 100644 cmd/client/main.go create mode 100644 cmd/loadtest/main.go create mode 100644 cmd/node/main.go create mode 100644 cmd/peerid/main.go create mode 100644 cmd/wallet/main.go create mode 100644 consensus/pbft.go create mode 100644 consensus/pbft_test.go create mode 100644 contracts/auction/auction.wasm create mode 100644 contracts/auction/auction_abi.json create mode 100644 contracts/auction/gen/main.go create mode 100644 contracts/counter/counter.wasm create mode 100644 contracts/counter/counter.wat create mode 100644 contracts/counter/counter_abi.json create mode 100644 contracts/counter/gen/main.go create mode 100644 contracts/counter/main.go create mode 100644 contracts/escrow/escrow.wasm create mode 100644 contracts/escrow/escrow_abi.json create mode 100644 contracts/escrow/gen/main.go create mode 100644 contracts/governance/gen/main.go create mode 100644 contracts/governance/governance.wasm create mode 100644 contracts/governance/governance_abi.json create mode 100644 contracts/hello_go/hello_go_abi.json create mode 100644 contracts/hello_go/main.go create mode 100644 contracts/name_registry/gen/main.go create mode 100644 contracts/name_registry/name_registry.wasm create mode 100644 contracts/name_registry/name_registry.wat create mode 100644 contracts/name_registry/name_registry_abi.json create mode 100644 contracts/sdk/dchain.go create mode 100644 contracts/sdk/dchain_stub.go create mode 100644 contracts/username_registry/gen/main.go create mode 100644 contracts/username_registry/username_registry.wasm create mode 100644 contracts/username_registry/username_registry_abi.json create mode 100644 deploy/UPDATE_STRATEGY.md create mode 100644 deploy/prod/Dockerfile.slim create mode 100644 deploy/prod/README.md create mode 100644 deploy/prod/caddy/Caddyfile create mode 100644 deploy/prod/docker-compose.yml create mode 100644 deploy/prod/node.env.example create mode 100644 deploy/prod/prometheus.yml create mode 100644 deploy/single/Caddyfile create mode 100644 deploy/single/README.md create mode 100644 deploy/single/docker-compose.yml create mode 100644 deploy/single/node.env.example create mode 100644 deploy/single/prometheus.yml create mode 100644 deploy/single/systemd/README.md create mode 100644 deploy/single/systemd/dchain-update.service create mode 100644 deploy/single/systemd/dchain-update.timer create mode 100644 deploy/single/update.sh create mode 100644 docker-compose.yml create mode 100644 docs/api/README.md create mode 100644 docs/api/chain.md create mode 100644 docs/api/contracts.md create mode 100644 docs/api/relay.md create mode 100644 docs/architecture.md create mode 100644 docs/cli/README.md create mode 100644 docs/contracts/README.md create mode 100644 docs/contracts/auction.md create mode 100644 docs/contracts/escrow.md create mode 100644 docs/contracts/governance.md create mode 100644 docs/contracts/username_registry.md create mode 100644 docs/development/README.md create mode 100644 docs/development/binary-wasm.md create mode 100644 docs/development/gas-model.md create mode 100644 docs/development/host-functions.md create mode 100644 docs/development/inter-contract.md create mode 100644 docs/development/tinygo.md create mode 100644 docs/node/README.md create mode 100644 docs/node/governance.md create mode 100644 docs/node/multi-server.md create mode 100644 docs/quickstart.md create mode 100644 economy/rewards.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 identity/identity.go create mode 100644 identity/identity_test.go create mode 100644 node/api_chain_v2.go create mode 100644 node/api_channels.go create mode 100644 node/api_common.go create mode 100644 node/api_contract.go create mode 100644 node/api_explorer.go create mode 100644 node/api_guards.go create mode 100644 node/api_onboarding.go create mode 100644 node/api_relay.go create mode 100644 node/api_routes.go create mode 100644 node/api_tokens.go create mode 100644 node/api_update_check.go create mode 100644 node/api_well_known.go create mode 100644 node/api_well_known_version.go create mode 100644 node/events.go create mode 100644 node/explorer/address.html create mode 100644 node/explorer/address.js create mode 100644 node/explorer/app.js create mode 100644 node/explorer/common.js create mode 100644 node/explorer/contract.html create mode 100644 node/explorer/contract.js create mode 100644 node/explorer/index.html create mode 100644 node/explorer/node.html create mode 100644 node/explorer/node.js create mode 100644 node/explorer/relays.html create mode 100644 node/explorer/relays.js create mode 100644 node/explorer/style.css create mode 100644 node/explorer/token.html create mode 100644 node/explorer/token.js create mode 100644 node/explorer/tokens.html create mode 100644 node/explorer/tokens.js create mode 100644 node/explorer/tx.html create mode 100644 node/explorer/tx.js create mode 100644 node/explorer/validators.html create mode 100644 node/explorer/validators.js create mode 100644 node/explorer_assets.go create mode 100644 node/metrics.go create mode 100644 node/sse.go create mode 100644 node/stats.go create mode 100644 node/swagger/index.html create mode 100644 node/swagger/openapi.json create mode 100644 node/swagger_assets.go create mode 100644 node/version/version.go create mode 100644 node/ws.go create mode 100644 p2p/host.go create mode 100644 p2p/sync.go create mode 100644 p2p/version_gossip.go create mode 100644 relay/envelope.go create mode 100644 relay/envelope_test.go create mode 100644 relay/keypair.go create mode 100644 relay/mailbox.go create mode 100644 relay/router.go create mode 100644 scripts/deploy_contracts.sh create mode 100644 testdata/README.md create mode 100644 testdata/node1.json create mode 100644 testdata/node2.json create mode 100644 testdata/node3.json create mode 100644 vm/abi.go create mode 100644 vm/gas.go create mode 100644 vm/host.go create mode 100644 vm/instrument.go create mode 100644 vm/vm.go create mode 100644 vm/vm_test.go create mode 100644 wallet/wallet.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..46350de --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Git history +.git +.gitignore + +# Build artifacts +bin/ +*.exe + +# BadgerDB data directories (may exist locally) +chaindata/ +mailboxdata/ +**/chaindata/ +**/mailboxdata/ + +# Key files (mounted as volume in compose) +node*.json +relay*.json +wallet*.json + +# IDE / editor +.vscode/ +.idea/ +*.code-workspace + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Go test cache +*_test.go.out +coverage.out diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e54f72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Build output +/bin/ +/node.exe +/client.exe +/peerid.exe +/wallet.exe +*.test +*.out + +# Local state from running node/relay directly (NOT in docker) +/chaindata/ +/mailboxdata/ +/node.json +/relay.json +/seeds.json + +# Go tool caches +.gobin/ +.gocache/ +.golangci-cache/ +.gomodcache/ +.gopath/ + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Docker compose local overrides +docker-compose.override.yml + +# Prod deploy secrets (operators must generate their own; never commit) +/deploy/prod/keys/ +/deploy/prod/node*.env +!/deploy/prod/node.env.example +/deploy/single/keys/ +/deploy/single/node.env +!/deploy/single/node.env.example +# Single-node update marker written by update.sh +/deploy/single/.last-update + +# Node modules (client-app has its own .gitignore too, this is belt+braces) +node_modules/ + +# Expo / React Native +.expo/ +*.log +dist/ +web-build/ + +# macOS / Windows cruft +.DS_Store +Thumbs.db + +# Claude Code / agent local state +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4542c2a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,181 @@ +# DChain CHANGELOG + +Consolidated record of what landed. Replaces the now-deleted +`REFACTOR_PLAN.md`, `NODE_ONBOARDING.md`, and `ROADMAP.md` — every numbered +item there is either shipped (listed below) or explicitly deferred. + +--- + +## Production-ready stack (shipped) + +### Consensus & chain +- **PBFT multi-sig validator admission**. `ADD_VALIDATOR` requires + ⌈2/3⌉ cosigs from the current set + candidate must have ≥ `MinValidatorStake` + (1 T) locked via STAKE. Same gate on forced `REMOVE_VALIDATOR`; self- + removal stays unilateral. +- **Equivocation slashing**. `SLASH` tx with `reason=equivocation` carries + both conflicting PREPARE/COMMIT messages as evidence; `ValidateEquivocation` + verifies on-chain — any node can report, no trust. Offender's stake is + burned and they're evicted from the set. +- **Liveness tracking**. PBFT records per-validator last-seen seqNum; + `LivenessReport()` + `MissedBlocks()` surface stalemates. Exposed via + `dchain_max_missed_blocks` Prometheus gauge. +- **Fair mempool**. Per-sender FIFO queues drained round-robin into + proposals; one spammer can't starve others. +- **Block-reward fix**. Synthetic BLOCK_REWARD transactions use `From=""` + so self-validators don't appear to pay themselves in history. + +### Storage & stability +- **Re-entrant-deadlock fix**. Dedicated `configMu` and `nativeMu` separate + from `c.mu` — applyTx can safely read config/native registry while + `AddBlock` holds the write lock. +- **BadgerDB tuning**. `WithValueLogFileSize(64 MiB)` + `WithNumVersionsToKeep(1)` + + background `StartValueLogGC` every 5 minutes + one-shot `CompactNow()` + at startup. Reclaims gigabytes from upgraded nodes automatically. +- **`TipIndex()`** — lock-free reads so `/api/blocks` and `/api/txs/recent` + never hang even when `AddBlock` is stuck. +- **Chronological tx index** (`txchron::`). `RecentTxs` + runs in O(limit) instead of O(empty blocks) — important when tps is low. +- **WASM VM timeout + `WithCloseOnContextDone`**. Any contract call aborts + at 30 s hard cap; gas metering evasion can no longer freeze the chain. + +### Native contracts +- **`native:username_registry`** (v2.1.0). Replaces WASM registry — 100× + faster, no VM failure surface. `register(name)` requires exact + `tx.Amount = 10 000 µT` (burned, visible in history). Min length 4, + lowercase `a-z 0-9 _ -`, first char letter, reserved words blacklist. +- **Native dispatcher** in `applyTx` → checks `native:*` IDs first, falls + through to WASM VM otherwise. ABI JSON + contract metadata surfaced via + `/api/contracts/:id` with `"native": true` flag. +- **Well-known auto-discovery**. `/api/well-known-contracts` returns + canonical contract IDs indexed by ABI name; native always wins. Client + auto-syncs on connect. + +### WebSocket gateway (push-based UX) +- **`GET /api/ws`** — persistent bidirectional JSON-framed connection. +- **Topics**: `blocks`, `tx`, `addr:`, `inbox:`, + `contract_log`, `contract:`, `typing:`. +- **`auth` op**. Client signs server-issued nonce with Ed25519; hub binds + connection to pubkey so scoped subscriptions (`addr:*`, `inbox:*`, + `typing:*`) are accepted only for owned identities. +- **`submit_tx` op**. Low-latency tx submission with correlated + `submit_ack` frame; removes the HTTP round-trip. Client falls back to + POST `/api/tx` automatically if WS is down. +- **Typing indicators**. Ephemeral `typing` op, authenticated, scoped to + recipient. Mobile client shows "печатает…" in chat header. +- **Per-connection quotas**. Max 10 connections / IP, 32 subs / connection. + Bounded outbox drops oldest on overflow with `{event:"lag"}` notice. +- **Fanout mirrors SSE**. `eventBus` dispatches to SSE + WS + future + consumers from one emit site. + +### Relay mailbox +- **Push notifications**. `Mailbox.SetOnStore` hook → `wsHub.EmitInbox(...)` + on every fresh envelope. Client's `useMessages` subscribes instead of + polling every 3 s. +- **Relay TTL**. `REGISTER_RELAY` and HEARTBEAT (from registered relays) + refresh a `relayhb:` timestamp; `/api/relays` filters anything + older than 2 hours. Stale relays are delisted automatically. + +### Node onboarding +- **`--join `** — multi-seed bootstrap. Tries each URL in + order, persists the live list to `/seeds.json` on first success so + subsequent restarts don't need the CLI flag. +- **`/api/network-info`** — one-shot payload (chain_id, genesis_hash, + validators, peers, contracts, stats) for joiners. +- **`/api/peers`** — live libp2p peer list with multiaddrs. +- **Genesis-hash verification**. A node with expected hash aborts if its + local block 0 doesn't match (protection against forged seeds). Override + with `--allow-genesis-mismatch` for migrations. +- **Gap-fill on gossip**. Blocks with `b.Index > tip+1` trigger + `SyncFromPeerFull` to the gossiping peer (rate-limited 1 per peer per + minute). Nodes recover from brief outages without restart. + +### API surface & security +- **Rate limiter** (`node/api_guards.go`). Per-IP token bucket on + `/api/tx` and `/v2/chain/transactions`: 10 tx/s, burst 20. +- **Request-size cap**. `/api/tx` body ≤ 64 KiB. +- **Timestamp validation**. ±1 h window on submit, refuses clock-skewed + or replayed txs. +- **Humanised errors in client**. `humanizeTxError` translates 429 / + 400+timestamp / 400+signature / network-failure into Russian user- + facing text. + +### Observability & ops +- **Prometheus `/metrics`**. Zero-dep in-tree implementation (`node/metrics.go`) + with counters (blocks, txs, submit accepted/rejected), gauges + (ws connections, peer count, max missed blocks), histogram (block + commit seconds). +- **Load test**. `cmd/loadtest` — N concurrent WS clients with auth + + scoped subs + TRANSFER at rate. Validates chain advances, reject rate, + ws-drop count. Smoke at 20 clients × 15 s → 136 accepted / 0 rejected. +- **Structured logging**. `--log-format=text|json` flag. JSON mode routes + both `slog.*` and legacy `log.Printf` through one JSON handler for + Loki/ELK ingestion. +- **Observer mode**. `--observer` (env `DCHAIN_OBSERVER`) disables PBFT + producer + heartbeat + auto-relay-register; node still gossips and + serves HTTP/WS. For horizontally-scaling read-only API frontends. + +### Deployment +- **`deploy/single/`** — one-node production bundle: + - Same `Dockerfile.slim` as the cluster variant. + - Compose stack: 1 node + Caddy + optional Prometheus/Grafana. + - Supports three operator-chosen access modes: + - Public (no token) — anyone can read + submit. + - Public reads, token-gated writes (`DCHAIN_API_TOKEN` set) — + reads stay open, submit tx requires `Authorization: Bearer`. + - Fully private (`DCHAIN_API_TOKEN` + `DCHAIN_API_PRIVATE`) — + every endpoint requires the token. + - Runbook covers three scenarios: genesis node, joiner, private. +- **`deploy/prod/`** — 3-validator cluster for federations/consortiums. +- **Access-token middleware** in `node/api_guards.go`: + - `withWriteTokenGuard` gates POST /api/tx and WS submit_tx. + - `withReadTokenGuard` gates reads when `--api-private` is set. + - WS upgrade applies the same check; `submit_tx` ops on a + non-authenticated connection are rejected with `submit_ack` + rejected. +- **All CLI flags accept `DCHAIN_*` env fallbacks** for Docker-driven + configuration, including the new `DCHAIN_API_TOKEN` / + `DCHAIN_API_PRIVATE`. + +### Client (React Native / Expo) +- WebSocket module `lib/ws.ts` with reconnect, auto-resubscribe, + auto-auth on reconnect. +- `useBalance`, `useContacts`, `useMessages` — all push-based with HTTP + polling fallback after 15 s disconnect. +- `useWellKnownContracts` — auto-syncs `settings.contractId` with node's + canonical registry. +- Safe-area-aware layout throughout. Tab bar no longer hides under home + indicator on iPhone. +- Username purchase UI with live validation (min 4, first letter, charset). +- Transaction detail sheet with system-tx handling (BLOCK_REWARD shows + "Сеть" as counterpart, not validator's self-pay). + +--- + +## Deliberately deferred + +- **Split `blockchain/chain.go`** into `state/`, `applytx/`, `mempool/`, + `index/`, `events/` subpackages. A ~2.5k-line single-file refactor is + high risk; to be attempted after the chain has been running in prod + long enough that regressions there would be caught fast. +- **Full `p2p/` rewrite with typed event channel.** The libp2p integration + works; event-bus was added at the node layer instead (see `node/events.go`). +- **Full mempool admission pricing** (gas-priced priority queues). + Current fair round-robin works within spam-proofing needs. + +--- + +## Compatibility notes + +- BadgerDB tuning is compatible with databases created by previous + versions; the first run reclaims old value-log space via `CompactNow()`. +- `AddValidatorPayload` / `RemoveValidatorPayload` gained a `cosigs` + field; older payloads without it still parse (default empty), but will + fail the ⌈2/3⌉ threshold on chains with >1 validator. +- `BLOCK_REWARD` transactions changed from `From=validator` to `From=""`. + Old indexed records keep their previous `From`; new ones use the new + shape. Explorer/client handle both. +- Registration fee for usernames moved from internal `ctx.Debit` to + `tx.Amount`. The WASM username_registry is superseded by + `native:username_registry`; well-known endpoint returns the native + version as canonical. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..7e4c178 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,229 @@ +# Decentralized Messenger — Project Context for Claude Code + +> Этот файл содержит полный контекст проекта из чата. Передай его в Claude Code командой: +> `claude --context CONTEXT.md` или просто открой в проекте и Claude Code подхватит его автоматически через CLAUDE.md + +--- + +## Суть проекта + +Полностью децентрализованный мессенджер с функциональностью уровня Telegram/ВКонтакте. +- Никакого центрального сервера +- Блокчейн как регулятор сети (не транспорт) +- Кастомный протокол с маскировкой трафика +- E2E шифрование верифицированное через блокчейн + +--- + +## Архитектура: четыре слоя + +### Слой 1 — Идентичность (L1 блокчейн) +Хранит только редкие важные события: +- Регистрация keypair (Ed25519) — раз в жизни +- Создание канала/чата — владелец и метаданные +- Изменение прав участников +- Открытие/закрытие платёжных state channels + +**Тела сообщений НИКОГДА не попадают в блокчейн.** + +### Слой 2 — Транспорт (relay-ноды) +- DHT маршрутизация (как BitTorrent) — нет центрального роутера +- Onion routing — каждый узел видит только следующий хоп +- Маскировка трафика — имитация HTTPS/QUIC или BitTorrent uTP +- Офлайн-буфер — зашифрованные конверты TTL 30 дней +- Proof of Relay — криптодоказательство честной доставки + +### Слой 3 — Хранение +- IPFS / Arweave — медиафайлы (content-addressed) +- Relay-кэш — горячая история, последние N сообщений +- Локальная зашифрованная БД (SQLite + NaCl) на устройстве +- Блокчейн — только хэши событий + +### Слой 4 — Приложение +Личные сообщения, группы, каналы, звонки (WebRTC P2P), сторис, посты, боты + +--- + +## Блокчейн: детали + +### Консенсус — PBFT (Tendermint-style) +- Финальность: 1–3 секунды +- Три фазы: Pre-prepare → Prepare → Commit +- Кворум: 2/3 валидаторов +- Валидаторы = операторы крупных relay-нод + +### Структура блока (Go) +```go +type Block struct { + Index uint64 + Timestamp time.Time + Transactions []*Transaction + PrevHash []byte + Hash []byte // SHA-256 + ValidatorSig []byte // Ed25519 + Validator string // pub_key валидатора +} + +type Transaction struct { + ID string + Type EventType // REGISTER_KEY | CREATE_CHANNEL | ADD_MEMBER | OPEN_PAY_CHAN ... + From string // pub_key отправителя + To string // pub_key получателя (если есть) + Payload []byte // json с данными события + Signature []byte // Ed25519 подпись From + Timestamp time.Time +} +``` + +### Хранение блоков +- Light nodes для мобильных клиентов (только заголовки) +- Sharding для валидаторов (каждый хранит свой шард) + +--- + +## Формат сообщений и постов + +### Личное сообщение — конверт +```go +type Envelope struct { + To []byte // pub_key получателя (для маршрутизации relay) + Nonce []byte // 24 случайных байта (anti-replay) + Ciphertext []byte // NaCl box: зашифровано pub_bob + priv_alice + SentAt int64 // unix timestamp (внутри шифра, снаружи не видно) +} +``` +Relay видит только `To`. Всё остальное — непрозрачный blob. + +Поток доставки: +1. Alice шифрует конверт pub_key Боба +2. Отправляет на свою relay-ноду (P2P) +3. Relay ищет Bob по DHT и доставляет (<50мс если онлайн) +4. Если офлайн — хранит конверт TTL 30 дней +5. Bob расшифровывает своим priv_key + +### Пост в канале +```go +type Post struct { + ChannelID string + SeqNum uint64 // монотонно растёт — клиент знает что пропустил + ContentHash []byte // sha256 тела = IPFS CID + AuthorSig []byte // подпись канала + Timestamp int64 + // Тело поста хранится в IPFS по ContentHash, не здесь +} +``` + +Поток публикации: +1. Автор создаёт Post, подписывает, загружает тело в IPFS +2. Relay-нода анонсирует через gossip-протокол: "в канале X пост #N" +3. Волна расходится по DHT к подписчикам +4. Клиент проверяет: sig автора → pub_key из блокчейна → sha256(тело) == ContentHash +5. Тело подгружается из IPFS лениво (lazy loading) + +Офлайн-синхронизация через seq_num: клиент хранит последний прочитанный номер, +при подключении запрашивает пропущенные у relay. + +--- + +## Экономика + +### Три механизма +1. **State Channels** — микроплатежи без газа на каждое действие (как Lightning Network) +2. **Proof of Relay** — нода зарабатывает токены за доказанную доставку сообщений +3. **Delegated Staking** — делегировать токены ноде оператора без своего сервера + +### Источники токенов +- Стартовый грант при регистрации +- PoW при создании keypair (CPU-барьер против Sybil) +- Лёгкая нода на телефоне (relay для соседей пока на зарядке) + +--- + +## Безопасность + +### E2E шифрование +- Signal Protocol (Double Ratchet) или Noise Protocol +- Sender Keys для групп — один симметричный ключ на группу +- Блокчейн решает проблему TOFU (верификация pub_key) + +### Защита метаданных +- Onion routing — relay не знает реального отправителя +- Sealed sender — сервер видит только получателя +- Маскировка трафика — QUIC / obfs4 / domain fronting + +### Sybil-защита +- PoW при регистрации +- Социальный граф — новый аккаунт без контактов имеет ограниченные права + +--- + +## Технический стек + +| Компонент | Библиотека | Причина | +|-----------|-----------|---------| +| Блокчейн | Cosmos SDK / Tendermint | Лучший PBFT на Go | +| P2P сеть | go-libp2p | Используется IPFS и Ethereum | +| БД блоков | BadgerDB | Go-native key-value | +| Криптография | crypto/ed25519 (stdlib) | В стандартной библиотеке | +| E2E шифрование | golang.org/x/crypto/nacl | NaCl/box | +| gRPC API | google.golang.org/grpc | Стандарт для Go | +| Relay протокол | кастомный поверх QUIC | Контроль маскировки | + +--- + +## Структура репозитория (планируемая) + +``` +/ +├── blockchain/ +│ ├── block.go # структура блока, хэширование, валидация +│ ├── chain.go # хранилище, state machine +│ └── types.go # Transaction, EventType и т.д. +├── consensus/ +│ └── pbft.go # PBFT: Pre-prepare → Prepare → Commit +├── identity/ +│ └── identity.go # keypair Ed25519, подпись, верификация +├── relay/ +│ ├── node.go # relay-нода: маршрутизация конвертов +│ ├── dht.go # DHT для discovery нод +│ └── buffer.go # офлайн-буфер с TTL +├── messaging/ +│ ├── envelope.go # личные сообщения (NaCl box) +│ └── channel.go # посты в каналах (IPFS + gossip) +├── crypto/ +│ ├── nacl.go # обёртки над NaCl box/secretbox +│ └── shamir.go # Shamir's Secret Sharing для recovery ключей +└── cmd/ + ├── node/ # relay-нода (сервер) + └── client/ # CLI клиент для тестирования +``` + +--- + +## Уже написанный код (в outputs/) + +- `blockchain/block.go` — Block, Transaction, GenesisBlock, ComputeHash, Validate +- `blockchain/chain.go` — Chain, AddBlock, applyTx, state (identities, channels) +- `consensus/consensus.go` — Node, HandleMessage, PBFT фазы, broadcast +- `identity/identity.go` — Generate, RegisterTx, SignMessage, VerifyMessage +- `main.go` — пример запуска, simulateBlockProduction, simulateMessageFlow + +--- + +## Следующие приоритеты для разработки + +1. Заменить in-memory map в chain.go на BadgerDB +2. Добавить go-libp2p для P2P между нодами +3. Реализовать DHT для discovery и маршрутизации +4. Написать relay/node.go с буфером конвертов +5. Написать messaging/envelope.go с NaCl шифрованием +6. View-change протокол в PBFT (смена лидера при падении) + +--- + +## Аналоги для изучения +- **Nostr** — минималистичный протокол, Lightning для relay +- **Tendermint** — лучший PBFT на Go, изучить view-change +- **go-libp2p** — P2P стек +- **Status.im** — мессенджер на Ethereum, токен SNT +- **lnd** — Lightning Network на Go (state channels) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f601d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# ---- build stage ---- +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Cache module downloads separately from source changes +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build-time version metadata (same convention as deploy/prod/Dockerfile.slim). +ARG VERSION_TAG=dev +ARG VERSION_COMMIT=none +ARG VERSION_DATE=unknown +ARG VERSION_DIRTY=false + +RUN LDFLAGS="\ + -X go-blockchain/node/version.Tag=${VERSION_TAG} \ + -X go-blockchain/node/version.Commit=${VERSION_COMMIT} \ + -X go-blockchain/node/version.Date=${VERSION_DATE} \ + -X go-blockchain/node/version.Dirty=${VERSION_DIRTY}" && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/node ./cmd/node && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/client ./cmd/client && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/wallet ./cmd/wallet && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/peerid ./cmd/peerid + +# ---- runtime stage ---- +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata netcat-openbsd + +COPY --from=builder /bin/node /usr/local/bin/node +COPY --from=builder /bin/client /usr/local/bin/client +COPY --from=builder /bin/wallet /usr/local/bin/wallet +COPY --from=builder /bin/peerid /usr/local/bin/peerid + +# Bake testnet keys into image so nodes always load consistent identities +# (avoids Windows volume-mount failures with ./testdata:/keys:ro) +COPY --from=builder /app/testdata/ /keys/ + +# Bake username_registry contract (messenger username with pricing) +COPY --from=builder /app/contracts/username_registry/username_registry.wasm /keys/username_registry.wasm +COPY --from=builder /app/contracts/username_registry/username_registry_abi.json /keys/username_registry_abi.json + +# Bake governance contract (on-chain parameter governance) +COPY --from=builder /app/contracts/governance/governance.wasm /keys/governance.wasm +COPY --from=builder /app/contracts/governance/governance_abi.json /keys/governance_abi.json + +# Bake auction contract (English auction with token escrow) +COPY --from=builder /app/contracts/auction/auction.wasm /keys/auction.wasm +COPY --from=builder /app/contracts/auction/auction_abi.json /keys/auction_abi.json + +# Bake escrow contract (two-party trustless escrow) +COPY --from=builder /app/contracts/escrow/escrow.wasm /keys/escrow.wasm +COPY --from=builder /app/contracts/escrow/escrow_abi.json /keys/escrow_abi.json + +# libp2p P2P port +EXPOSE 4001/tcp +# HTTP stats + explorer + relay API +EXPOSE 8080/tcp + +ENTRYPOINT ["/usr/local/bin/node"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..54f15f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,94 @@ +.PHONY: build test up down reset deploy logs logs-node1 logs-node2 logs-node3 \ + status peer-ids rebuild docker-clean test-local + +# ── Сборка ─────────────────────────────────────────────────────────────────── + +build: + go build -o bin/node$(EXE) ./cmd/node + go build -o bin/client$(EXE) ./cmd/client + go build -o bin/wallet$(EXE) ./cmd/wallet + go build -o bin/peerid$(EXE) ./cmd/peerid + +test: + go test ./... + +# ── Docker: запуск / остановка ─────────────────────────────────────────────── + +## Собрать образ и запустить все три ноды +up: + docker compose up --build -d + @printf "\n Explorer → http://localhost:8081\n" + @printf " node2 → http://localhost:8082\n" + @printf " node3 → http://localhost:8083\n\n" + @printf " Задеплоить контракты: make deploy\n\n" + +## Остановить ноды (данные сохраняются) +down: + docker compose down + +## Полный сброс: остановить + удалить тома с данными +reset: + docker compose down -v + @printf "\n Данные удалены. Запустите 'make up' для чистого старта.\n\n" + +## Задеплоить 4 production-контракта +deploy: + docker compose --profile deploy run --rm deploy + +# ── Логи ───────────────────────────────────────────────────────────────────── + +logs: + docker compose logs -f + +logs-node1: + docker compose logs -f node1 + +logs-node2: + docker compose logs -f node2 + +logs-node3: + docker compose logs -f node3 + +# ── Статус ─────────────────────────────────────────────────────────────────── + +status: + @printf "\n── Контейнеры ──────────────────────────────────────────\n" + docker compose ps + @printf "\n── Сети ────────────────────────────────────────────────\n" + docker network ls | grep dchain || true + @printf "\n── Backbone IP-адреса ──────────────────────────────────\n" + @docker inspect -f '{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ + node1 node2 node3 2>/dev/null | grep 172.30 || true + @printf "\n" + +# ── Peer IDs ───────────────────────────────────────────────────────────────── + +## Показать peer ID и backbone multiaddr для всех нод +peer-ids: build + @printf "\n── node1 ────────────────────────────────────────────────\n" + bin/peerid$(EXE) --key testdata/node1.json --ip 172.30.0.11 --port 4001 + @printf "\n── node2 ────────────────────────────────────────────────\n" + bin/peerid$(EXE) --key testdata/node2.json --ip 172.30.0.12 --port 4001 + @printf "\n── node3 ────────────────────────────────────────────────\n" + bin/peerid$(EXE) --key testdata/node3.json --ip 172.30.0.13 --port 4001 + @printf "\n" + +# ── Docker: служебные ──────────────────────────────────────────────────────── + +rebuild: + docker compose build --no-cache + docker compose up -d + +docker-clean: + docker compose down -v --rmi local + +# ── Локальный тест (одна нода) ──────────────────────────────────────────────── + +test-local: build + @rm -rf /tmp/testchain + MSYS_NO_PATHCONV=1 bin/node$(EXE) \ + --genesis --db /tmp/testchain --key testdata/node1.json \ + --listen /ip4/0.0.0.0/tcp/4001 & + @sleep 8 && kill $$(pgrep -f "bin/node") 2>/dev/null || true + @echo "--- chain info ---" + bin/client$(EXE) info --db /tmp/testchain diff --git a/README.md b/README.md new file mode 100644 index 0000000..0977509 --- /dev/null +++ b/README.md @@ -0,0 +1,310 @@ +# DChain + +Блокчейн-стек для децентрализованного мессенджера: +- **PBFT** консенсус с multi-sig validator governance и equivocation slashing +- **Native Go контракты** рядом с WASM (wazero) — нулевая задержка для + системных сервисов типа username registry +- **WebSocket push API** — клиент не опрашивает, все события прилетают + на соединение +- **E2E-шифрованный relay mailbox** на libp2p gossipsub с TTL live-detection +- **Prometheus `/metrics`**, Caddy auto-HTTPS, observer mode, load-test +- React Native / Expo мессенджер (`client-app/`) + +## Содержание + +- [Быстрый старт (dev)](#быстрый-старт-dev) +- [Продакшен деплой](#продакшен-деплой) +- [Клиент](#клиент) +- [Архитектура](#архитектура) +- [Контракты](#контракты) +- [REST / WebSocket API](#rest--websocket-api) +- [CLI](#cli) +- [Мониторинг](#мониторинг) +- [Тесты](#тесты) +- [История изменений](#история-изменений) + +--- + +## Быстрый старт (dev) + +3 валидатора в docker-compose с замоканной «интернет» топологией: + +```bash +docker compose up --build -d +open http://localhost:8081 # Explorer главной ноды +curl -s http://localhost:8081/api/netstats # синхронность ноды +``` + +После поднятия нативный `username_registry` уже доступен — отдельный +`--profile deploy` больше не нужен. Системные контракты регистрируются +в Go-коде при запуске (см. `blockchain/native.go`). + +## Продакшен деплой + +Два варианта, по масштабу: + +### 🔸 Single-node (`deploy/single/`) + +**Рекомендуется для личного/первого узла.** Один узел + Caddy TLS + опциональный Prometheus. + +```bash +cd deploy/single + +# 1. Ключ ноды (один раз, сохранить в безопасном месте) +docker build -t dchain-node-slim -f ../prod/Dockerfile.slim ../.. +mkdir -p keys +docker run --rm --entrypoint /usr/local/bin/client \ + -v "$PWD/keys:/out" dchain-node-slim \ + keygen --out /out/node.json + +# 2. Конфиг +cp node.env.example node.env && $EDITOR node.env +# минимум: DCHAIN_ANNOUNCE=, DOMAIN=, ACME_EMAIL= + +# 3. Вверх +docker compose up -d + +# 4. Проверка +curl -s https://$DOMAIN/api/netstats +curl -s https://$DOMAIN/api/well-known-version +``` + +#### Модели доступа + +| Режим | `DCHAIN_API_TOKEN` | `DCHAIN_API_PRIVATE` | Поведение | +|-------|:------------------:|:--------------------:|-----------| +| Public (default) | не задан | — | Все могут читать и писать | +| Public reads / token writes | задан | `false` | Читать — любой; submit tx — только с токеном | +| Fully private | задан | `true` | Всё требует `Authorization: Bearer ` | + +#### UI / Swagger — включать или нет? + +| Нужно | `DCHAIN_DISABLE_UI` | `DCHAIN_DISABLE_SWAGGER` | Где что открыто | +|-------|:-------------------:|:------------------------:|-----------------| +| Публичная с эксплорером + живой docs | не задано | не задано | `/` Explorer, `/swagger` UI, `/api/*`, `/metrics` | +| Headless API-нода с открытой OpenAPI | `true` | не задано | `/swagger` UI, `/api/*`, `/metrics` (Explorer выкл.) | +| Личная hardened | `true` | `true` | только `/api/*` + `/metrics` | + +Флаги читаются и из CLI (`--disable-ui`, `--disable-swagger`), и из env. +`/api/*` JSON-поверхность регистрируется всегда — отключить её можно +только на уровне Caddy / firewall. + +#### Auto-update + +```ini +# node.env — когда проект у вас в Gitea +DCHAIN_UPDATE_SOURCE_URL=https://gitea.example.com/api/v1/repos/OWNER/REPO/releases/latest +UPDATE_ALLOW_MAJOR=false +``` + +```bash +# Hourly systemd timer с 15-мин jitter +sudo cp deploy/single/systemd/dchain-update.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now dchain-update.timer +``` + +Скрипт `deploy/single/update.sh` читает `/api/update-check`, делает +semver-guarded `git checkout tag`, ребилдит образ с injected версией, +smoke-test `node --version`, recreate, health poll. + +Подробные сценарии (первая нода, joiner, приватная, headless) + +полный API reference + systemd-интеграция + troubleshooting — в +**[`deploy/single/README.md`](deploy/single/README.md)**. + +### 🔹 Multi-validator (`deploy/prod/`) + +3 validator'а в PBFT-кворуме, Caddy с ip_hash для WS-стикинесса и +least-conn для REST. Для федераций / консорциумов — см. +`deploy/prod/README.md`. + +## Клиент + +```bash +cd client-app +npm install +npx expo start # Expo Dev Tools — iOS / Android / Web +``` + +React Native + Expo + NativeWind. Ключевые экраны: + +| Экран | Описание | +|-------|----------| +| Welcome / Create Account | Генерация Ed25519 + X25519 keypair | +| Chat List | Диалоги + real-time via WS (`inbox:`) | +| Chat | E2E NaCl, typing-индикатор, day-separators | +| Contact Requests | Входящие запросы (push через `addr:`) | +| Add Contact | Поиск по `@username` (native registry) или hex | +| Wallet | Баланс, история (push), кошельковые action buttons | +| Settings | Нода, `@username` покупка, экспорт ключа | + +Клиент подсоединяется к одной ноде через HTTP + WebSocket: +- **WS для всего real-time** (balance, inbox, contacts, tx commit). +- **HTTP fallback** через 15 s после обрыва, автоматически. +- **Auto-discovery** канонического `username_registry` через + `/api/well-known-contracts`. + +## Архитектура + +``` +┌────────────┐ libp2p ┌────────────┐ libp2p ┌────────────┐ +│ node1 │◄─────pubsub──►│ node2 │◄─────pubsub──►│ node3 │ +│ validator │ │ validator │ │ validator │ +│ + relay │ │ + relay │ │ + relay │ +└─────┬──────┘ └─────┬──────┘ └─────┬──────┘ + │ │ │ + │ HTTPS / wss (via Caddy) │ │ + ▼ ▼ ▼ + mobile / web / CLI clients (load-balanced ip_hash for WS) +``` + +Слои (`blockchain/`): +- `chain.go` — блочная машина (applyTx, AddBlock, BadgerDB) +- `native.go` — системные Go-контракты (интерфейс + registry) +- `native_username.go` — реализация username_registry +- `equivocation.go` — проверка evidence для SLASH +- `types.go` — транзакции, payloads + +Сети (`p2p/`): +- gossipsub topics `dchain/tx/v1`, `dchain/blocks/v1` +- стрим-протокол `/dchain/sync/1.0.0` для catch-up +- mDNS + DHT для peer discovery + +Консенсус (`consensus/pbft.go`): +- Pre-prepare → Prepare → Commit, 2/3 quorum +- liveness tracking, equivocation detection (`recordVote`, `TakeEvidence`) +- per-sender FIFO мемпул + round-robin drain до `MaxTxsPerBlock` + +Node service layer (`node/`): +- HTTP API + SSE + **WebSocket hub** с auth и topic-based fanout +- Prometheus `/metrics` (zero-dep) +- event bus (`events.go`) — SSE + WS + future consumers с одного emit'а +- rate-limiter + body-size cap на tx submit + +Relay (`relay/`): +- encrypted envelope routing через gossipsub +- BadgerDB mailbox для offline-получателей (TTL 7 дней) +- `SetOnStore` hook push'ит новые envelopes в WS + +## Контракты + +**Системные (native Go)** — зарегистрированы при запуске ноды: + +| ID | Назначение | ABI | +|----|-----------|-----| +| `native:username_registry` | `@username` → адрес | `register`, `resolve`, `lookup`, `transfer`, `release` | + +**Пользовательские (WASM)** — деплоятся через `DEPLOY_CONTRACT` tx. +TinyGo SDK + host functions (get_state, transfer, get_caller, log, +call_contract, …). Ручной деплой из `scripts/deploy_contracts.sh` или +CLI `client deploy-contract --wasm ...`. + +Обзор ABI / примеры — `docs/contracts/` (отдельные файлы на auction, +escrow, governance, username_registry). + +## REST / WebSocket API + +### Chain + +| Endpoint | Описание | +|----------|----------| +| `GET /api/netstats` | total_blocks, tx_count, supply, peer count | +| `GET /api/blocks?limit=N` | Последние блоки | +| `GET /api/block/{index}` | Конкретный блок | +| `GET /api/tx/{id}` | Транзакция по ID | +| `GET /api/txs/recent?limit=N` | Последние tx (O(limit)) | +| `GET /api/address/{pubkey}` | Баланс + история tx | +| `GET /api/validators` | Активный validator set | +| `GET /api/network-info` | One-shot bootstrap payload (chain_id, genesis, peers, validators, contracts) | +| `GET /api/peers` | Живые libp2p peer'ы | +| `GET /api/well-known-contracts` | Канонические контракты (native + WASM) | +| `GET /api/contracts/{id}` | Метаданные контракта, `"native": true/false` | +| `GET /api/contracts/{id}/state/{key}` | Прямое чтение state | +| `GET /api/relays` | Список relay-нод (filtered TTL) | +| `POST /api/tx` | Submit signed tx (rate-limited, size-capped) | + +### Real-time + +- `GET /api/events` — Server-Sent Events (classic, 1-way) +- `GET /api/ws` — **WebSocket** (bidirectional, recommended) + +WS protocol — см. `node/ws.go`: + +```json +{ "op": "auth", "pubkey": "...", "sig": "..." } +{ "op": "subscribe", "topic": "addr:..." | "inbox:..." | "typing:..." | "blocks" | "tx" } +{ "op": "unsubscribe", "topic": "..." } +{ "op": "submit_tx", "tx": {...}, "id": "client-req-id" } +{ "op": "typing", "to": "" } +{ "op": "ping" } +``` + +Server push: + +```json +{ "event": "hello", "chain_id": "...", "auth_nonce": "..." } +{ "event": "block", "data": {...} } +{ "event": "tx", "data": {...} } +{ "event": "inbox", "data": { id, recipient_pub, sender_pub, sent_at } } +{ "event": "typing", "data": { from, to } } +{ "event": "submit_ack", "id": "...", "status": "accepted|rejected", "reason": "..." } +``` + +Scoped топики (`addr:`, `inbox:`, `typing:`) требуют auth. Без auth +доступны только публичные (`blocks`, `tx`, `contract_log`). + +## CLI + +```bash +client keygen --out key.json +client balance --key key.json --node URL +client transfer --key key.json --to --amount <µT> --node URL +client call-contract --key key.json --contract native:username_registry \ + --method register --args '["alice"]' --amount 10000 \ + --node URL +client add-validator --key key.json --target --cosigs pub:sig,pub:sig +client admit-sign --key validator.json --target +client remove-validator --key key.json --target +# ... полный список — `client` без аргументов +``` + +Node flags (все читают `DCHAIN_*` env fallbacks): +- `--db`, `--listen`, `--announce`, `--peers`, `--validators` +- `--join http://seed1:8080,http://seed2:8080` (multi-seed, persisted) +- `--genesis`, `--observer`, `--register-relay` +- `--log-format text|json`, `--allow-genesis-mismatch` + +## Мониторинг + +Prometheus endpoint `/metrics` на каждой ноде. Ключевые метрики: + +``` +dchain_blocks_total # committed blocks count +dchain_txs_total # tx count +dchain_tx_submit_accepted_total +dchain_tx_submit_rejected_total +dchain_ws_connections # current WS sockets +dchain_peer_count_live # live libp2p peer count +dchain_max_missed_blocks # worst validator liveness gap +dchain_block_commit_seconds # histogram of AddBlock time +``` + +Grafana dashboard + provisioning — `deploy/prod/grafana/` (create as +needed; Prometheus data source is auto-wired in compose profile +`monitor`). + +## Тесты + +```bash +go test ./... # blockchain + consensus + relay + identity + vm +go run ./cmd/loadtest \ + --node http://localhost:8081 \ + --funder testdata/node1.json \ + --clients 50 --duration 60s # end-to-end WS + submit_tx + native contract path +``` + +## История изменений + +Подробный список того, что сделано за последнюю итерацию (стабилизация +чейна, governance, WS gateway, observability, native contracts, +deployment) — `CHANGELOG.md`. diff --git a/blockchain/block.go b/blockchain/block.go new file mode 100644 index 0000000..2a7ff8e --- /dev/null +++ b/blockchain/block.go @@ -0,0 +1,139 @@ +package blockchain + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "time" +) + +// Block is the fundamental unit of the chain. +type Block struct { + Index uint64 `json:"index"` + Timestamp time.Time `json:"timestamp"` + Transactions []*Transaction `json:"transactions"` + PrevHash []byte `json:"prev_hash"` + Hash []byte `json:"hash"` // SHA-256 over canonical fields + ValidatorSig []byte `json:"validator_sig"` // Ed25519 sig over Hash + Validator string `json:"validator"` // hex pub key of signing validator + // TotalFees collected in this block (credited to Validator) + TotalFees uint64 `json:"total_fees"` +} + +// canonicalBytes returns a deterministic byte slice for hashing. +// Order: index | timestamp | prev_hash | tx_hashes | total_fees | validator +func (b *Block) canonicalBytes() []byte { + var buf bytes.Buffer + + // 8-byte big-endian index + idxBuf := make([]byte, 8) + binary.BigEndian.PutUint64(idxBuf, b.Index) + buf.Write(idxBuf) + + // 8-byte unix nano timestamp + tsBuf := make([]byte, 8) + binary.BigEndian.PutUint64(tsBuf, uint64(b.Timestamp.UnixNano())) + buf.Write(tsBuf) + + buf.Write(b.PrevHash) + + // Hash each transaction and include its hash + for _, tx := range b.Transactions { + h := txHash(tx) + buf.Write(h) + } + + // 8-byte fees + feesBuf := make([]byte, 8) + binary.BigEndian.PutUint64(feesBuf, b.TotalFees) + buf.Write(feesBuf) + + buf.WriteString(b.Validator) + + return buf.Bytes() +} + +// txHash returns SHA-256 of the canonical transaction bytes. +func txHash(tx *Transaction) []byte { + data, _ := json.Marshal(tx) + h := sha256.Sum256(data) + return h[:] +} + +// ComputeHash fills b.Hash from the canonical bytes. +func (b *Block) ComputeHash() { + sum := sha256.Sum256(b.canonicalBytes()) + b.Hash = sum[:] +} + +// Sign signs b.Hash with the given Ed25519 private key and stores the signature. +func (b *Block) Sign(privKey ed25519.PrivateKey) { + b.ValidatorSig = ed25519.Sign(privKey, b.Hash) +} + +// Validate checks the block's structural integrity: +// 1. Hash matches canonical bytes +// 2. ValidatorSig is a valid Ed25519 signature over Hash +// 3. PrevHash is provided (except genesis) +func (b *Block) Validate(prevHash []byte) error { + // Recompute and compare hash + sum := sha256.Sum256(b.canonicalBytes()) + if !bytes.Equal(sum[:], b.Hash) { + return errors.New("block hash mismatch") + } + + // Verify validator signature + pubKeyBytes, err := hex.DecodeString(b.Validator) + if err != nil { + return errors.New("invalid validator pub key hex") + } + if !ed25519.Verify(ed25519.PublicKey(pubKeyBytes), b.Hash, b.ValidatorSig) { + return errors.New("invalid validator signature") + } + + // Check chain linkage (skip for genesis) + if b.Index > 0 { + if !bytes.Equal(b.PrevHash, prevHash) { + return errors.New("prev_hash mismatch") + } + } + + // Validate each transaction's fee minimum + var totalFees uint64 + for _, tx := range b.Transactions { + if tx.Fee < MinFee { + return errors.New("transaction fee below minimum") + } + totalFees += tx.Fee + } + if totalFees != b.TotalFees { + return errors.New("total_fees mismatch") + } + + return nil +} + +// GenesisBlock creates the first block with no transactions. +// It is signed by the bootstrap validator. +func GenesisBlock(validatorPubHex string, privKey ed25519.PrivateKey) *Block { + b := &Block{ + Index: 0, + Timestamp: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + Transactions: []*Transaction{}, + PrevHash: bytes.Repeat([]byte{0}, 32), + Validator: validatorPubHex, + TotalFees: 0, + } + b.ComputeHash() + b.Sign(privKey) + return b +} + +// HashHex returns the block hash as a hex string. +func (b *Block) HashHex() string { + return hex.EncodeToString(b.Hash) +} diff --git a/blockchain/chain.go b/blockchain/chain.go new file mode 100644 index 0000000..ace73af --- /dev/null +++ b/blockchain/chain.go @@ -0,0 +1,2589 @@ +package blockchain + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "sync" + "time" + + badger "github.com/dgraph-io/badger/v4" +) + +// RelayHeartbeatTTL is how long a relay registration stays "live" without a +// refresh. Clients pick from the live list in /api/relays; anything with +// last-heartbeat older than this is omitted. +// +// Set to 2 hours so a validator that heartbeats hourly (the default +// heartbeatLoop interval) can miss ONE beat without being delisted — +// tolerating a brief restart or network glitch. +const RelayHeartbeatTTL int64 = 2 * 3600 // seconds + +// ErrTxFailed is a sentinel wrapped around any business-logic rejection inside +// applyTx (bad fee, insufficient balance, missing fields, etc.). +// AddBlock uses errors.Is(err, ErrTxFailed) to skip the individual transaction +// rather than rejecting the entire block, preventing chain stalls caused by +// a single malformed or untimely transaction. +var ErrTxFailed = errors.New("tx failed") + +// Key prefixes in BadgerDB +const ( + prefixBlock = "block:" // block: → Block JSON + prefixHeight = "height" // height → uint64 + prefixBalance = "balance:" // balance: → uint64 + prefixIdentity = "id:" // id: → RegisterKeyPayload JSON + prefixChannel = "chan:" // chan: → CreateChannelPayload JSON + prefixChanMember = "chan-member:" // chan-member:: → "" (presence = member) + prefixWalletBind = "walletbind:" // walletbind: → wallet_pubkey (string) + prefixReputation = "rep:" // rep: → RepStats JSON + prefixPayChan = "paychan:" // paychan: → PayChanState JSON + prefixRelay = "relay:" // relay: → RegisterRelayPayload JSON + prefixRelayHB = "relayhb:" // relayhb: → unix seconds (int64) of last HB + prefixContactIn = "contact_in:" // contact_in:: → contactRecord JSON + prefixValidator = "validator:" // validator: → "" (presence = active) + prefixContract = "contract:" // contract: → ContractRecord JSON + prefixContractState = "cstate:" // cstate:: → raw bytes + prefixContractLog = "clog:" // clog::: → ContractLogEntry JSON + prefixStake = "stake:" // stake: → uint64 staked amount + prefixToken = "token:" // token: → TokenRecord JSON + prefixTokenBal = "tokbal:" // tokbal:: → uint64 token balance + prefixNFT = "nft:" // nft: → NFTRecord JSON + prefixNFTOwner = "nftowner:" // nftowner:: → "" (index by owner) + // prefixTxChron gives O(limit) recent-tx scans without walking empty blocks. + // Key layout: txchron:: → tx_id (string). + // Writes happen in indexBlock for every non-synthetic tx. + prefixTxChron = "txchron:" // txchron:: → tx_id +) + +// ContractVM is the interface used by applyTx to execute WASM contracts. +// The vm package provides the concrete implementation; the interface lives here +// to avoid a circular import (vm imports blockchain/types, not blockchain/chain). +type ContractVM interface { + // Validate compiles the WASM bytes and returns an error if they are invalid. + // Called during DEPLOY_CONTRACT to reject bad modules before storing them. + Validate(ctx context.Context, wasmBytes []byte) error + + // Call executes the named method of a deployed contract. + // wasmBytes is the compiled WASM; env provides host function callbacks. + // Returns gas consumed. Returns ErrOutOfGas (wrapping ErrTxFailed) on exhaustion. + Call(ctx context.Context, contractID string, wasmBytes []byte, method string, argsJSON []byte, gasLimit uint64, env VMHostEnv) (gasUsed uint64, err error) +} + +// VMHostEnv is the callback interface passed to ContractVM.Call. +// Implementations are created per-transaction and wrap the live badger.Txn. +type VMHostEnv interface { + GetState(key []byte) ([]byte, error) + SetState(key, value []byte) error + GetBalance(pubKeyHex string) (uint64, error) + Transfer(from, to string, amount uint64) error + GetCaller() string + GetBlockHeight() uint64 + GetContractTreasury() string + Log(msg string) + // CallContract executes a method on another deployed contract (inter-contract call). + // The caller of the sub-contract is set to the current contract's ID. + // gasLimit caps the sub-call; actual gas consumed is returned. + // Returns ErrTxFailed if the target contract is not found or the call fails. + CallContract(contractID, method string, argsJSON []byte, gasLimit uint64) (uint64, error) +} + +// RepStats are stored per public key and updated as blocks are committed. +type RepStats struct { + BlocksProduced uint64 `json:"blocks_produced"` + RelayProofs uint64 `json:"relay_proofs"` + SlashCount uint64 `json:"slash_count"` + Heartbeats uint64 `json:"heartbeats"` + // Score is re-computed on every read; stored for fast API queries. + Score int64 `json:"score"` +} + +// ComputeScore calculates the reputation score from raw counters. +func (r RepStats) ComputeScore() int64 { + return int64(r.BlocksProduced)*10 + + int64(r.RelayProofs)*1 + + int64(r.Heartbeats)/10 - + int64(r.SlashCount)*500 +} + +// Rank returns a human-readable tier string. +func (r RepStats) Rank() string { + switch s := r.ComputeScore(); { + case s >= 1000: + return "Validator" + case s >= 100: + return "Trusted" + case s >= 10: + return "Active" + default: + return "Observer" + } +} + +// Chain is the canonical state machine backed by BadgerDB. +type Chain struct { + db *badger.DB + mu sync.RWMutex + tip *Block + vm ContractVM // optional; set via SetVM before processing contract txs + + // govContractID and any other live-tunable config live under configMu, + // NOT c.mu. Chain-config reads happen inside applyTx (e.g. + // GetEffectiveGasPrice for CALL_CONTRACT), which runs under c.mu.Lock() + // held by AddBlock. Re-locking c.mu for read would deadlock because + // sync.RWMutex is not re-entrant on the same goroutine. + configMu sync.RWMutex + govContractID string + + // native maps contract ID → in-process Go handler. Registered via + // RegisterNative once at startup (genesis or on-disk reload). When a + // CALL_CONTRACT tx references an ID in this map, the dispatcher skips + // the WASM VM entirely and calls the Go handler directly. + // + // Protected by its own mutex for the same reason as configMu above: + // lookupNative is called from applyTx under c.mu.Lock(), and we must + // not re-acquire c.mu. + native map[string]NativeContract + nativeMu sync.RWMutex +} + +// SetVM wires a ContractVM implementation into the chain. +// Must be called before any DEPLOY_CONTRACT or CALL_CONTRACT transactions are processed. +func (c *Chain) SetVM(vm ContractVM) { + c.mu.Lock() + defer c.mu.Unlock() + c.vm = vm +} + +// SetGovernanceContract configures the governance contract ID used for +// dynamic chain parameters (gas_price, relay_fee, etc.). Safe to call at any time. +// Uses configMu (not c.mu) so it never blocks against in-flight AddBlock. +func (c *Chain) SetGovernanceContract(id string) { + c.configMu.Lock() + defer c.configMu.Unlock() + c.govContractID = id + log.Printf("[CHAIN] governance contract linked: %s", id) +} + +// GetGovParam reads a live parameter from the governance contract's state. +// Returns ("", false) if no governance contract is configured or the key is not set. +// Uses configMu so it's safe to call from within applyTx (where c.mu is held). +func (c *Chain) GetGovParam(key string) (string, bool) { + c.configMu.RLock() + id := c.govContractID + c.configMu.RUnlock() + if id == "" { + return "", false + } + var val []byte + err := c.db.View(func(txn *badger.Txn) error { + dbKey := []byte(prefixContractState + id + ":param:" + key) + item, err := txn.Get(dbKey) + if err != nil { + return err + } + return item.Value(func(v []byte) error { + val = make([]byte, len(v)) + copy(val, v) + return nil + }) + }) + if err != nil { + return "", false + } + return string(val), true +} + +// GetEffectiveGasPrice returns the current gas price in µT per gas unit. +// If a governance contract is configured and has set gas_price, that value is used. +// Otherwise falls back to the DefaultGasPrice constant. +func (c *Chain) GetEffectiveGasPrice() uint64 { + if val, ok := c.GetGovParam("gas_price"); ok { + var p uint64 + if _, err := fmt.Sscanf(val, "%d", &p); err == nil && p > 0 { + return p + } + } + return GasPrice +} + +// NewChain opens (or creates) the BadgerDB at dbPath and returns a Chain. +// +// Storage tuning rationale: +// +// - `WithValueLogFileSize(64 MiB)` — default is 1 GiB, which means every +// value-log file reserves a full gigabyte on disk even when nearly +// empty. On a low-traffic chain (tens of thousands of mostly-empty +// blocks) that produced multi-GB databases that would never shrink. +// 64 MiB files rotate more often so value-log GC can reclaim space. +// +// - `WithNumVersionsToKeep(1)` — we never read historical versions of a +// key; every write overwrites the previous one. Telling Badger this +// lets L0 compaction discard stale versions immediately instead of +// waiting for the versions-kept quota to fill. +// +// - `WithCompactL0OnClose(true)` — finish outstanding compaction on a +// clean shutdown so the next startup reads a tidy LSM. +// +// The caller SHOULD start the background value-log GC loop via +// Chain.StartValueLogGC(ctx) — without it, reclaimable vlog bytes are never +// actually freed and the DB grows monotonically. +func NewChain(dbPath string) (*Chain, error) { + opts := badger.DefaultOptions(dbPath). + WithLogger(nil). + WithValueLogFileSize(64 << 20). // 64 MiB per vlog (default 1 GiB) + WithNumVersionsToKeep(1). // no multi-version reads, drop old + WithCompactL0OnClose(true) + + db, err := badger.Open(opts) + if err != nil { + return nil, fmt.Errorf("open badger: %w", err) + } + // Run any pending schema migrations BEFORE loadTip — migrations may + // rewrite the very keys loadTip reads. See schema_migrations.go for the + // versioning contract. + if err := runMigrations(db); err != nil { + _ = db.Close() + return nil, fmt.Errorf("schema migrations: %w", err) + } + c := &Chain{db: db} + tip, err := c.loadTip() + if err != nil { + return nil, err + } + c.tip = tip + return c, nil +} + +// CompactNow runs a one-shot aggressive value-log GC and L0 compaction. +// Intended to be called at startup on nodes upgraded from a version that +// had no background GC, so accumulated garbage (potentially gigabytes) can +// be reclaimed without waiting for the periodic loop. +// +// Uses a lower discard ratio (0.25 vs 0.5 for the periodic loop) so even +// mildly-fragmented vlog files get rewritten. Capped at 64 iterations so we +// can never loop indefinitely — a 4 GiB DB at 64 MiB vlog-file-size has at +// most 64 files, so this caps at the true theoretical maximum. +func (c *Chain) CompactNow() { + const maxPasses = 64 + passes := 0 + start := time.Now() + for c.db.RunValueLogGC(0.25) == nil { + passes++ + if passes >= maxPasses { + log.Printf("[CHAIN] CompactNow: reached pass cap (%d) after %s", maxPasses, time.Since(start)) + return + } + } + if passes > 0 { + log.Printf("[CHAIN] CompactNow: reclaimed %d vlog file(s) in %s", passes, time.Since(start)) + } +} + +// StartValueLogGC runs Badger's value-log garbage collector in a background +// goroutine for the lifetime of ctx. +// +// Without this the chain DB grows monotonically: every overwrite of a +// small hot key like `height` or `netstats` leaves the old value pinned +// in the active value-log file until GC reclaims it. After enough block +// commits a node ends up multiple GB on disk even though actual live +// chain state is a few megabytes. +// +// The loop runs every 5 minutes and drains GC cycles until Badger says +// there is nothing more worth rewriting. `0.5` is the discard ratio: +// Badger rewrites a vlog file only if at least 50% of its bytes are +// garbage, which balances I/O cost against space reclamation. +func (c *Chain) StartValueLogGC(ctx context.Context) { + go func() { + t := time.NewTicker(5 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + // RunValueLogGC returns nil when it successfully rewrote + // one file; keep draining until it returns an error + // (typically badger.ErrNoRewrite). + for c.db.RunValueLogGC(0.5) == nil { + } + } + } + }() +} + +// Close closes the underlying BadgerDB. +func (c *Chain) Close() error { return c.db.Close() } + +// Height returns index of the latest block (0 if empty). +func (c *Chain) Height() uint64 { + c.mu.RLock() + defer c.mu.RUnlock() + if c.tip == nil { + return 0 + } + return c.tip.Index +} + +// Tip returns the latest block or nil if chain is empty. +func (c *Chain) Tip() *Block { + c.mu.RLock() + defer c.mu.RUnlock() + return c.tip +} + +// TipIndex reads the committed tip height directly from BadgerDB, bypassing +// the chain mutex. Returns 0 if the chain is uninitialized. +// +// Use this from read-only API handlers (e.g. /api/blocks, /api/txs/recent) +// that must not hang when AddBlock is holding the write lock — for example +// during a slow contract call or an extended consensus round. A slightly +// stale height is better than a stuck explorer. +func (c *Chain) TipIndex() uint64 { + var h uint64 + _ = c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixHeight)) + if err != nil { + return nil // 0 is a valid "empty chain" result + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &h) + }) + }) + return h +} + +// AddBlock validates and appends a finalized block to the chain, +// applying all state mutations atomically. +// +// Logs a warning if apply takes longer than slowApplyThreshold so we can see +// in the logs exactly which block/tx is causing the chain to stall — a slow +// CALL_CONTRACT that exhausts gas, a very large DEPLOY_CONTRACT, or genuine +// BadgerDB contention. +const slowApplyThreshold = 2 * time.Second + +func (c *Chain) AddBlock(b *Block) error { + started := time.Now() + c.mu.Lock() + defer func() { + c.mu.Unlock() + if dt := time.Since(started); dt > slowApplyThreshold { + log.Printf("[CHAIN] SLOW AddBlock idx=%d txs=%d took=%s — investigate applyTx path", + b.Index, len(b.Transactions), dt) + } + }() + + var prevHash []byte + if c.tip != nil { + prevHash = c.tip.Hash + } else { + if b.Index != 0 { + return errors.New("chain is empty but received non-genesis block") + } + prevHash = b.PrevHash + } + + if err := b.Validate(prevHash); err != nil { + return fmt.Errorf("block validation: %w", err) + } + + if err := c.db.Update(func(txn *badger.Txn) error { + // Persist block + val, err := json.Marshal(b) + if err != nil { + return err + } + if err := txn.Set([]byte(blockKey(b.Index)), val); err != nil { + return err + } + // Update height + hv, err := json.Marshal(b.Index) + if err != nil { + return err + } + if err := txn.Set([]byte(prefixHeight), hv); err != nil { + return err + } + // Apply transactions. + // Business-logic failures (ErrTxFailed) skip the individual tx so that + // a single bad transaction never causes the block — and the entire chain + // height — to stall. Infrastructure failures (DB errors) still abort. + // Only fees of SUCCESSFULLY applied txs are credited to the validator; + // skipped txs contribute nothing (avoids minting tokens from thin air). + var collectedFees uint64 + gasUsedByTx := make(map[string]uint64) + seenInBlock := make(map[string]bool, len(b.Transactions)) + for _, tx := range b.Transactions { + // Guard against duplicate tx IDs within the same block or already + // committed in a previous block (defense-in-depth for mempool bugs). + if seenInBlock[tx.ID] { + log.Printf("[CHAIN] block %d: duplicate tx %s in same block — skipped", b.Index, tx.ID) + continue + } + seenInBlock[tx.ID] = true + if _, err := txn.Get([]byte(prefixTxRecord + tx.ID)); err == nil { + log.Printf("[CHAIN] block %d: tx %s already committed — skipped", b.Index, tx.ID) + continue + } + gasUsed, err := c.applyTx(txn, tx) + if err != nil { + if errors.Is(err, ErrTxFailed) { + senderBal, _ := c.readBalance(txn, tx.From) + log.Printf("[CHAIN] block %d: tx %s (%s) skipped — %v [sender %s balance: %d µT]", + b.Index, tx.ID, tx.Type, err, + tx.From[:min(8, len(tx.From))], senderBal) + continue + } + return fmt.Errorf("apply tx %s: %w", tx.ID, err) + } + if gasUsed > 0 { + gasUsedByTx[tx.ID] = gasUsed + } + collectedFees += tx.Fee + } + // Credit validator (or their bound wallet). + // Genesis block (index 0): one-time allocation of fixed supply. + // All other blocks: validator earns only the transaction fees — no minting. + rewardTarget, err := c.resolveRewardTarget(txn, b.Validator) + if err != nil { + return fmt.Errorf("resolve reward target: %w", err) + } + if b.Index == 0 { + if err := c.creditBalance(txn, rewardTarget, GenesisAllocation); err != nil { + return fmt.Errorf("genesis allocation: %w", err) + } + } else if collectedFees > 0 { + if err := c.creditBalance(txn, rewardTarget, collectedFees); err != nil { + return fmt.Errorf("credit validator fees: %w", err) + } + } + // Update validator reputation + if err := c.incrementRep(txn, b.Validator, func(r *RepStats) { + r.BlocksProduced++ + }); err != nil { + return err + } + // Index transactions and update network stats + if err := c.indexBlock(txn, b, gasUsedByTx); err != nil { + return err + } + return nil + }); err != nil { + return err + } + + c.tip = b + return nil +} + +// GetBlock returns the block at the given index. +func (c *Chain) GetBlock(index uint64) (*Block, error) { + var b Block + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(blockKey(index))) + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &b) + }) + }) + if err != nil { + return nil, err + } + return &b, nil +} + +// Balance returns µT balance for a public key. +func (c *Chain) Balance(pubKeyHex string) (uint64, error) { + var bal uint64 + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixBalance + pubKeyHex)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &bal) + }) + }) + return bal, err +} + +// Identity returns the RegisterKeyPayload for a public key, or nil. +func (c *Chain) Identity(pubKeyHex string) (*RegisterKeyPayload, error) { + var p RegisterKeyPayload + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixIdentity + pubKeyHex)) + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &p) + }) + }) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, nil + } + return &p, err +} + +// Channel returns the CreateChannelPayload for a channel ID, or nil. +func (c *Chain) Channel(channelID string) (*CreateChannelPayload, error) { + var p CreateChannelPayload + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixChannel + channelID)) + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &p) + }) + }) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, nil + } + return &p, err +} + +// ChannelMembers returns the public keys of all members added to channelID. +func (c *Chain) ChannelMembers(channelID string) ([]string, error) { + prefix := []byte(fmt.Sprintf("%s%s:", prefixChanMember, channelID)) + var members []string + err := c.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + key := string(it.Item().Key()) + // key = "chan-member::" + parts := strings.SplitN(key, ":", 3) + if len(parts) == 3 { + members = append(members, parts[2]) + } + } + return nil + }) + return members, err +} + +// WalletBinding returns the payout wallet pub key bound to a node, or "" if none. +func (c *Chain) WalletBinding(nodePubKey string) (string, error) { + var walletPubKey string + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixWalletBind + nodePubKey)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { + walletPubKey = string(val) + return nil + }) + }) + return walletPubKey, err +} + +// PayChannel returns the PayChanState for a channel ID, or nil if not found. +func (c *Chain) PayChannel(channelID string) (*PayChanState, error) { + var state PayChanState + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixPayChan + channelID)) + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &state) + }) + }) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, nil + } + return &state, err +} + +// PayChanSigPayload returns the canonical bytes both parties sign to open a channel. +// Use this from the wallet CLI to produce SigB before submitting an OPEN_PAY_CHAN tx. +func PayChanSigPayload(channelID, partyA, partyB string, depositA, depositB, expiryBlock uint64) []byte { + return payChanSigPayload(channelID, partyA, partyB, depositA, depositB, expiryBlock) +} + +// PayChanCloseSigPayload returns the canonical bytes both parties sign to close a channel. +func PayChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64) []byte { + return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce) +} + +// Reputation returns the reputation stats for a public key. +func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) { + var r RepStats + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixReputation + pubKeyHex)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &r) + }) + }) + if err != nil { + return RepStats{}, err + } + r.Score = r.ComputeScore() + return r, nil +} + +// --- internal --- + +func blockKey(index uint64) string { + return fmt.Sprintf("%s%020d", prefixBlock, index) +} + +func (c *Chain) loadTip() (*Block, error) { + var height uint64 + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixHeight)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &height) + }) + }) + if err != nil { + return nil, err + } + if height == 0 { + // Check if genesis exists + var genesis Block + err2 := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(blockKey(0))) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &genesis) + }) + }) + if err2 != nil { + return nil, err2 + } + if genesis.Hash != nil { + return &genesis, nil + } + return nil, nil + } + return c.GetBlock(height) +} + +// resolveRewardTarget returns the wallet pub key to credit for a validator. +// If the validator has a bound wallet, returns that; otherwise returns their own pub key. +func (c *Chain) resolveRewardTarget(txn *badger.Txn, validatorPubKey string) (string, error) { + item, err := txn.Get([]byte(prefixWalletBind + validatorPubKey)) + if errors.Is(err, badger.ErrKeyNotFound) { + return validatorPubKey, nil + } + if err != nil { + return "", err + } + var target string + err = item.Value(func(val []byte) error { + target = string(val) + return nil + }) + if err != nil || target == "" { + return validatorPubKey, nil + } + return target, nil +} + +// applyTx applies one transaction within txn. +// Returns (gasUsed, error); gasUsed is non-zero only for CALL_CONTRACT. +func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) { + switch tx.Type { + + case EventRegisterKey: + var p RegisterKeyPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: REGISTER_KEY bad payload: %v", ErrTxFailed, err) + } + if tx.Fee < RegistrationFee { + return 0, fmt.Errorf("%w: REGISTER_KEY fee %d µT below minimum %d µT", + ErrTxFailed, tx.Fee, RegistrationFee) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("REGISTER_KEY debit: %w", err) + } + val, _ := json.Marshal(p) + if err := txn.Set([]byte(prefixIdentity+tx.From), val); err != nil { + return 0, err + } + + case EventCreateChannel: + var p CreateChannelPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: CREATE_CHANNEL bad payload: %v", ErrTxFailed, err) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("CREATE_CHANNEL debit: %w", err) + } + val, _ := json.Marshal(p) + if err := txn.Set([]byte(prefixChannel+p.ChannelID), val); err != nil { + return 0, err + } + + case EventAddMember: + var p AddMemberPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: ADD_MEMBER bad payload: %v", ErrTxFailed, err) + } + if p.ChannelID == "" { + return 0, fmt.Errorf("%w: ADD_MEMBER: channel_id required", ErrTxFailed) + } + if _, err := txn.Get([]byte(prefixChannel + p.ChannelID)); err != nil { + if errors.Is(err, badger.ErrKeyNotFound) { + return 0, fmt.Errorf("%w: ADD_MEMBER: channel %q not found", ErrTxFailed, p.ChannelID) + } + return 0, err + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("ADD_MEMBER debit: %w", err) + } + member := tx.To + if member == "" { + member = tx.From + } + if err := txn.Set([]byte(fmt.Sprintf("%s%s:%s", prefixChanMember, p.ChannelID, member)), []byte{}); err != nil { + return 0, err + } + + case EventTransfer: + senderBal, _ := c.readBalance(txn, tx.From) + log.Printf("[CHAIN] TRANSFER %s→%s amount=%d fee=%d senderBal=%d", + tx.From[:min(8, len(tx.From))], tx.To[:min(8, len(tx.To))], + tx.Amount, tx.Fee, senderBal) + if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil { + return 0, fmt.Errorf("TRANSFER debit: %w", err) + } + if err := c.creditBalance(txn, tx.To, tx.Amount); err != nil { + return 0, fmt.Errorf("credit recipient: %w", err) + } + + case EventRelayProof: + var p RelayProofPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err) + } + if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 { + return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed) + } + authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT) + ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig) + if err != nil || !ok { + return 0, fmt.Errorf("%w: invalid relay fee authorization signature", ErrTxFailed) + } + if err := c.debitBalance(txn, p.SenderPubKey, p.FeeUT); err != nil { + return 0, fmt.Errorf("RELAY_PROOF debit: %w", err) + } + target, err := c.resolveRewardTarget(txn, p.RelayPubKey) + if err != nil { + return 0, err + } + if err := c.creditBalance(txn, target, p.FeeUT); err != nil { + return 0, fmt.Errorf("credit relay fee: %w", err) + } + if err := c.incrementRep(txn, p.RelayPubKey, func(r *RepStats) { + r.RelayProofs++ + }); err != nil { + return 0, err + } + + case EventBindWallet: + var p BindWalletPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: BIND_WALLET bad payload: %v", ErrTxFailed, err) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("BIND_WALLET debit: %w", err) + } + if err := txn.Set([]byte(prefixWalletBind+tx.From), []byte(p.WalletPubKey)); err != nil { + return 0, err + } + + case EventSlash: + var p SlashPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: SLASH bad payload: %v", ErrTxFailed, err) + } + if p.OffenderPubKey == "" { + return 0, fmt.Errorf("%w: SLASH: offender_pub_key required", ErrTxFailed) + } + // Sender must be a validator — non-validators can't trigger slashing + // without gumming up the chain with spurious reports. + fromIsValidator, err := c.isValidatorTxn(txn, tx.From) + if err != nil { + return 0, err + } + if !fromIsValidator { + return 0, fmt.Errorf("%w: SLASH: sender is not a current validator", ErrTxFailed) + } + // Only "equivocation" is cryptographically verifiable on-chain; + // reject other reasons until we implement their proofs (downtime + // is handled via auto-removal, not slashing). + if p.Reason != "equivocation" { + return 0, fmt.Errorf("%w: SLASH: only reason=equivocation is supported on-chain, got %q", + ErrTxFailed, p.Reason) + } + var ev EquivocationEvidence + if err := json.Unmarshal(p.Evidence, &ev); err != nil { + return 0, fmt.Errorf("%w: SLASH: bad evidence: %v", ErrTxFailed, err) + } + if err := ValidateEquivocation(p.OffenderPubKey, &ev); err != nil { + return 0, fmt.Errorf("%w: SLASH: %v", ErrTxFailed, err) + } + // Pay the sender's tx fee (they did work to produce the evidence). + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("SLASH fee debit: %w", err) + } + // Burn offender's stake (preferred — bonded amount), fall back to + // balance if stake < SlashAmount. Either way, the tokens are + // destroyed — not redirected to the reporter, to keep incentives + // clean (reporters profit only from healthier chain, not bounties). + stake := c.readStake(txn, p.OffenderPubKey) + if stake >= SlashAmount { + if err := c.writeStake(txn, p.OffenderPubKey, stake-SlashAmount); err != nil { + return 0, fmt.Errorf("SLASH stake burn: %w", err) + } + } else { + if stake > 0 { + if err := c.writeStake(txn, p.OffenderPubKey, 0); err != nil { + return 0, fmt.Errorf("SLASH stake burn: %w", err) + } + } + // Burn the rest from liquid balance (best-effort; ignore + // insufficient-balance error so the slash still counts). + remaining := SlashAmount - stake + _ = c.debitBalance(txn, p.OffenderPubKey, remaining) + } + // Eject from the validator set — slashed validators are off the + // committee permanently (re-admission requires a fresh + // ADD_VALIDATOR with stake). + if err := txn.Delete([]byte(prefixValidator + p.OffenderPubKey)); err != nil && err != badger.ErrKeyNotFound { + return 0, fmt.Errorf("SLASH remove validator: %w", err) + } + if err := c.incrementRep(txn, p.OffenderPubKey, func(r *RepStats) { + r.SlashCount++ + }); err != nil { + return 0, err + } + log.Printf("[CHAIN] SLASH: offender=%s reason=%s reporter=%s amount=%d µT", + p.OffenderPubKey[:min(8, len(p.OffenderPubKey))], p.Reason, + tx.From[:min(8, len(tx.From))], SlashAmount) + + case EventHeartbeat: + var p HeartbeatPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: HEARTBEAT bad payload: %v", ErrTxFailed, err) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("HEARTBEAT debit: %w", err) + } + if err := c.incrementRep(txn, tx.From, func(r *RepStats) { + r.Heartbeats++ + }); err != nil { + return 0, err + } + // Also refresh the relay-heartbeat timestamp if the sender is a + // registered relay. This reuses the existing hourly HEARTBEAT tx + // so relay-only nodes don't need to pay for a dedicated keep- + // alive; one tx serves both purposes. + if _, err := txn.Get([]byte(prefixRelay + tx.From)); err == nil { + if err := c.writeRelayHeartbeat(txn, tx.From, tx.Timestamp.Unix()); err != nil { + return 0, err + } + } + + case EventRegisterRelay: + var p RegisterRelayPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: REGISTER_RELAY bad payload: %v", ErrTxFailed, err) + } + if p.X25519PubKey == "" { + return 0, fmt.Errorf("%w: REGISTER_RELAY: x25519_pub_key is required", ErrTxFailed) + } + val, _ := json.Marshal(p) + if err := txn.Set([]byte(prefixRelay+tx.From), val); err != nil { + return 0, err + } + // Seed the heartbeat so the relay is immediately reachable via + // /api/relays. Without this a fresh relay wouldn't appear until + // its first heartbeat tx commits (~1 hour default), making the + // register tx look silent. + if err := c.writeRelayHeartbeat(txn, tx.From, tx.Timestamp.Unix()); err != nil { + return 0, err + } + + case EventContactRequest: + var p ContactRequestPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: CONTACT_REQUEST bad payload: %v", ErrTxFailed, err) + } + if tx.To == "" { + return 0, fmt.Errorf("%w: CONTACT_REQUEST: recipient (to) is required", ErrTxFailed) + } + if tx.Amount < MinContactFee { + return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d", + ErrTxFailed, tx.Amount, MinContactFee) + } + if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil { + return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err) + } + if err := c.creditBalance(txn, tx.To, tx.Amount); err != nil { + return 0, fmt.Errorf("credit contact target: %w", err) + } + rec := contactRecord{ + Status: string(ContactPending), + Intro: p.Intro, + FeeUT: tx.Amount, + TxID: tx.ID, + CreatedAt: tx.Timestamp.Unix(), + } + val, _ := json.Marshal(rec) + key := prefixContactIn + tx.To + ":" + tx.From + if err := txn.Set([]byte(key), val); err != nil { + return 0, fmt.Errorf("store contact record: %w", err) + } + + case EventAcceptContact: + if tx.To == "" { + return 0, fmt.Errorf("%w: ACCEPT_CONTACT: requester (to) is required", ErrTxFailed) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("ACCEPT_CONTACT debit: %w", err) + } + key := prefixContactIn + tx.From + ":" + tx.To + if err := c.updateContactStatus(txn, key, ContactAccepted); err != nil { + return 0, fmt.Errorf("%w: accept contact: %v", ErrTxFailed, err) + } + + case EventBlockContact: + if tx.To == "" { + return 0, fmt.Errorf("%w: BLOCK_CONTACT: sender (to) is required", ErrTxFailed) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("BLOCK_CONTACT debit: %w", err) + } + key := prefixContactIn + tx.From + ":" + tx.To + var rec contactRecord + item, err := txn.Get([]byte(key)) + if err == nil { + _ = item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }) + } + rec.Status = string(ContactBlocked) + val, _ := json.Marshal(rec) + if err := txn.Set([]byte(key), val); err != nil { + return 0, fmt.Errorf("store block record: %w", err) + } + + case EventAddValidator: + if tx.To == "" { + return 0, fmt.Errorf("%w: ADD_VALIDATOR: target pub key (to) is required", ErrTxFailed) + } + fromIsValidator, err := c.isValidatorTxn(txn, tx.From) + if err != nil { + return 0, err + } + if !fromIsValidator { + return 0, fmt.Errorf("%w: ADD_VALIDATOR: %s is not a current validator", ErrTxFailed, tx.From) + } + + // Decode admission payload early so we can read CoSignatures. + var admitP AddValidatorPayload + if len(tx.Payload) > 0 { + if err := json.Unmarshal(tx.Payload, &admitP); err != nil { + return 0, fmt.Errorf("%w: ADD_VALIDATOR bad payload: %v", ErrTxFailed, err) + } + } + + // ── Stake gate ───────────────────────────────────────────────── + // Candidate must have locked at least MinValidatorStake before the + // admission tx is accepted. Prevents sybil admissions. + if stake := c.readStake(txn, tx.To); stake < MinValidatorStake { + return 0, fmt.Errorf("%w: ADD_VALIDATOR: candidate has %d µT staked, need %d µT", + ErrTxFailed, stake, MinValidatorStake) + } + + // ── Multi-sig gate ───────────────────────────────────────────── + // Count approvals: the sender (a validator, checked above) is 1. + // Each valid CoSignature from a DISTINCT current validator adds 1. + // Require ⌈2/3⌉ of the current validator set to admit. + currentSet, err := c.validatorSetTxn(txn) + if err != nil { + return 0, err + } + required := (2*len(currentSet) + 2) / 3 // ceil(2N/3) + if required < 1 { + required = 1 + } + digest := AdmitDigest(tx.To) + approvers := map[string]struct{}{tx.From: {}} + for _, cs := range admitP.CoSignatures { + // Reject cosigs from non-validators or signatures that don't + // verify. Silently duplicates are dropped. + if _, alreadyIn := approvers[cs.PubKey]; alreadyIn { + continue + } + if !contains(currentSet, cs.PubKey) { + continue + } + pubBytes, err := hex.DecodeString(cs.PubKey) + if err != nil || len(pubBytes) != ed25519.PublicKeySize { + continue + } + if !ed25519.Verify(ed25519.PublicKey(pubBytes), digest, cs.Signature) { + continue + } + approvers[cs.PubKey] = struct{}{} + } + if len(approvers) < required { + return 0, fmt.Errorf("%w: ADD_VALIDATOR: %d of %d approvals (need %d = ceil(2/3) of %d validators)", + ErrTxFailed, len(approvers), len(currentSet), required, len(currentSet)) + } + + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("ADD_VALIDATOR debit: %w", err) + } + if err := txn.Set([]byte(prefixValidator+tx.To), []byte{}); err != nil { + return 0, fmt.Errorf("store validator: %w", err) + } + log.Printf("[CHAIN] ADD_VALIDATOR: admitted %s (%d/%d approvals)", + tx.To[:min(8, len(tx.To))], len(approvers), len(currentSet)) + + case EventRemoveValidator: + if tx.To == "" { + return 0, fmt.Errorf("%w: REMOVE_VALIDATOR: target pub key (to) is required", ErrTxFailed) + } + fromIsValidator, err := c.isValidatorTxn(txn, tx.From) + if err != nil { + return 0, err + } + if !fromIsValidator { + return 0, fmt.Errorf("%w: REMOVE_VALIDATOR: %s is not a current validator", ErrTxFailed, tx.From) + } + + // Self-removal is always allowed — a validator should be able to + // leave the set gracefully without needing peers' approval. + selfRemove := tx.From == tx.To + if !selfRemove { + // Forced removal requires ⌈2/3⌉ cosigs on RemoveDigest(target). + // Same shape as ADD_VALIDATOR; keeps governance symmetric. + var rmP RemoveValidatorPayload + if len(tx.Payload) > 0 { + if err := json.Unmarshal(tx.Payload, &rmP); err != nil { + return 0, fmt.Errorf("%w: REMOVE_VALIDATOR bad payload: %v", ErrTxFailed, err) + } + } + currentSet, err := c.validatorSetTxn(txn) + if err != nil { + return 0, err + } + required := (2*len(currentSet) + 2) / 3 + if required < 1 { + required = 1 + } + digest := RemoveDigest(tx.To) + approvers := map[string]struct{}{tx.From: {}} + for _, cs := range rmP.CoSignatures { + if _, already := approvers[cs.PubKey]; already { + continue + } + if !contains(currentSet, cs.PubKey) { + continue + } + pubBytes, err := hex.DecodeString(cs.PubKey) + if err != nil || len(pubBytes) != ed25519.PublicKeySize { + continue + } + if !ed25519.Verify(ed25519.PublicKey(pubBytes), digest, cs.Signature) { + continue + } + approvers[cs.PubKey] = struct{}{} + } + if len(approvers) < required { + return 0, fmt.Errorf("%w: REMOVE_VALIDATOR: %d of %d approvals (need %d = ceil(2/3))", + ErrTxFailed, len(approvers), len(currentSet), required) + } + } + + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("REMOVE_VALIDATOR debit: %w", err) + } + if err := txn.Delete([]byte(prefixValidator + tx.To)); err != nil && err != badger.ErrKeyNotFound { + return 0, fmt.Errorf("remove validator: %w", err) + } + if selfRemove { + log.Printf("[CHAIN] REMOVE_VALIDATOR: %s self-removed", tx.To[:min(8, len(tx.To))]) + } else { + log.Printf("[CHAIN] REMOVE_VALIDATOR: removed %s (multi-sig)", tx.To[:min(8, len(tx.To))]) + } + + case EventOpenPayChan: + if err := c.applyOpenPayChan(txn, tx); err != nil { + return 0, fmt.Errorf("%w: open paychan: %v", ErrTxFailed, err) + } + + case EventClosePayChan: + if err := c.applyClosePayChan(txn, tx); err != nil { + return 0, fmt.Errorf("%w: close paychan: %v", ErrTxFailed, err) + } + + case EventDeployContract: + if c.vm == nil { + return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: VM not configured on this node", ErrTxFailed) + } + var p DeployContractPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: DEPLOY_CONTRACT bad payload: %v", ErrTxFailed, err) + } + if p.WASMBase64 == "" || p.ABIJson == "" { + return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: wasm_b64 and abi_json are required", ErrTxFailed) + } + if tx.Fee < MinDeployFee { + return 0, fmt.Errorf("%w: DEPLOY_CONTRACT fee %d < MinDeployFee %d", + ErrTxFailed, tx.Fee, MinDeployFee) + } + import64 := func(s string) ([]byte, error) { + buf := make([]byte, len(s)) + n, err := decodeBase64(s, buf) + return buf[:n], err + } + wasmBytes, err := import64(p.WASMBase64) + if err != nil { + return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: invalid base64 wasm: %v", ErrTxFailed, err) + } + if err := c.vm.Validate(context.Background(), wasmBytes); err != nil { + return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: invalid WASM: %v", ErrTxFailed, err) + } + contractID := computeContractID(tx.From, wasmBytes) + if _, dbErr := txn.Get([]byte(prefixContract + contractID)); dbErr == nil { + return 0, fmt.Errorf("%w: DEPLOY_CONTRACT: contract %s already deployed", ErrTxFailed, contractID) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("DEPLOY_CONTRACT debit: %w", err) + } + var height uint64 + if item, hErr := txn.Get([]byte(prefixHeight)); hErr == nil { + _ = item.Value(func(val []byte) error { return json.Unmarshal(val, &height) }) + } + rec := ContractRecord{ + ContractID: contractID, + WASMBytes: wasmBytes, + ABIJson: p.ABIJson, + DeployerPub: tx.From, + DeployedAt: height, + } + val, _ := json.Marshal(rec) + if err := txn.Set([]byte(prefixContract+contractID), val); err != nil { + return 0, fmt.Errorf("store contract: %w", err) + } + log.Printf("[CHAIN] DEPLOY_CONTRACT id=%s deployer=%s height=%d wasmSize=%d", + contractID, tx.From[:min(8, len(tx.From))], height, len(wasmBytes)) + + case EventCallContract: + var p CallContractPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: CALL_CONTRACT bad payload: %v", ErrTxFailed, err) + } + if p.ContractID == "" || p.Method == "" { + return 0, fmt.Errorf("%w: CALL_CONTRACT: contract_id and method are required", ErrTxFailed) + } + if p.GasLimit == 0 { + return 0, fmt.Errorf("%w: CALL_CONTRACT: gas_limit must be > 0", ErrTxFailed) + } + + // ── Native dispatch ────────────────────────────────────────────── + // System contracts (username_registry etc.) implemented in Go run + // here, bypassing wazero entirely. This eliminates a whole class + // of VM-hang bugs and cuts per-call latency ~100×. + if nc := c.lookupNative(p.ContractID); nc != nil { + gasPrice := c.GetEffectiveGasPrice() + maxGasCost := p.GasLimit * gasPrice + if err := c.debitBalance(txn, tx.From, tx.Fee+maxGasCost); err != nil { + return 0, fmt.Errorf("CALL_CONTRACT debit: %w", err) + } + var height uint64 + if hi, hErr := txn.Get([]byte(prefixHeight)); hErr == nil { + _ = hi.Value(func(val []byte) error { return json.Unmarshal(val, &height) }) + } + nctx := &NativeContext{ + Txn: txn, + ContractID: p.ContractID, + Caller: tx.From, + TxID: tx.ID, + BlockHeight: height, + TxAmount: tx.Amount, // payment attached to this call + chain: c, + } + gasUsed, callErr := nc.Call(nctx, p.Method, []byte(p.ArgsJSON)) + if gasUsed > p.GasLimit { + gasUsed = p.GasLimit + } + if callErr != nil { + // Refund unused gas but keep fee debited — prevents spam. + if refund := (p.GasLimit - gasUsed) * gasPrice; refund > 0 { + _ = c.creditBalance(txn, tx.From, refund) + } + return 0, fmt.Errorf("%w: CALL_CONTRACT %s.%s: %v", ErrTxFailed, p.ContractID, p.Method, callErr) + } + // Success: refund remaining gas. + if refund := (p.GasLimit - gasUsed) * gasPrice; refund > 0 { + if err := c.creditBalance(txn, tx.From, refund); err != nil { + log.Printf("[CHAIN] CALL_CONTRACT native gas refund failed: %v", err) + } + } + log.Printf("[CHAIN] native CALL_CONTRACT id=%s method=%s caller=%s gasUsed=%d", + p.ContractID, p.Method, tx.From[:min(8, len(tx.From))], gasUsed) + return gasUsed, nil + } + + // ── WASM path ──────────────────────────────────────────────────── + if c.vm == nil { + return 0, fmt.Errorf("%w: CALL_CONTRACT: VM not configured on this node", ErrTxFailed) + } + item, err := txn.Get([]byte(prefixContract + p.ContractID)) + if err != nil { + if errors.Is(err, badger.ErrKeyNotFound) { + return 0, fmt.Errorf("%w: CALL_CONTRACT: contract %s not found", ErrTxFailed, p.ContractID) + } + return 0, err + } + var rec ContractRecord + if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil { + return 0, fmt.Errorf("%w: CALL_CONTRACT: corrupt contract record: %v", ErrTxFailed, err) + } + // Use effective gas price (may be overridden by governance contract). + gasPrice := c.GetEffectiveGasPrice() + // Pre-charge fee + maximum possible gas cost upfront. + maxGasCost := p.GasLimit * gasPrice + if err := c.debitBalance(txn, tx.From, tx.Fee+maxGasCost); err != nil { + return 0, fmt.Errorf("CALL_CONTRACT debit: %w", err) + } + var height uint64 + if hi, hErr := txn.Get([]byte(prefixHeight)); hErr == nil { + _ = hi.Value(func(val []byte) error { return json.Unmarshal(val, &height) }) + } + env := newChainHostEnv(txn, p.ContractID, tx.From, tx.ID, height, c) + // Hard wall-clock budget per contract call. Even if gas metering + // fails or the contract dodges the function-listener (tight loop of + // unhooked opcodes), WithCloseOnContextDone(true) on the runtime + // will abort the call once the deadline fires. Prevents a single + // bad tx from freezing the entire chain — as happened with the + // username_registry.register hang. + callCtx, callCancel := context.WithTimeout(context.Background(), 30*time.Second) + gasUsed, callErr := c.vm.Call( + callCtx, + p.ContractID, rec.WASMBytes, + p.Method, []byte(p.ArgsJSON), + p.GasLimit, env, + ) + callCancel() + if callErr != nil { + // Refund unused gas even on error (gas already consumed stays charged). + if refund := (p.GasLimit - gasUsed) * gasPrice; refund > 0 { + _ = c.creditBalance(txn, tx.From, refund) + } + return 0, fmt.Errorf("%w: CALL_CONTRACT %s.%s: %v", ErrTxFailed, p.ContractID, p.Method, callErr) + } + // Refund unused gas back to caller. + if refund := (p.GasLimit - gasUsed) * gasPrice; refund > 0 { + if err := c.creditBalance(txn, tx.From, refund); err != nil { + log.Printf("[CHAIN] CALL_CONTRACT gas refund failed (refund=%d µT): %v", refund, err) + } + } + log.Printf("[CHAIN] CALL_CONTRACT id=%s method=%s caller=%s gasUsed=%d/%d gasCost=%d µT refund=%d µT", + p.ContractID, p.Method, tx.From[:min(8, len(tx.From))], + gasUsed, p.GasLimit, gasUsed*gasPrice, (p.GasLimit-gasUsed)*gasPrice) + return gasUsed, nil + + case EventStake: + if tx.Amount == 0 { + return 0, fmt.Errorf("%w: STAKE: amount must be > 0", ErrTxFailed) + } + if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil { + return 0, fmt.Errorf("STAKE debit: %w", err) + } + current := c.readStake(txn, tx.From) + if err := c.writeStake(txn, tx.From, current+tx.Amount); err != nil { + return 0, fmt.Errorf("STAKE write: %w", err) + } + log.Printf("[CHAIN] STAKE pubkey=%s amount=%d µT total=%d µT", + tx.From[:min(8, len(tx.From))], tx.Amount, current+tx.Amount) + + case EventUnstake: + staked := c.readStake(txn, tx.From) + if staked == 0 { + return 0, fmt.Errorf("%w: UNSTAKE: no active stake", ErrTxFailed) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("UNSTAKE fee debit: %w", err) + } + if err := c.writeStake(txn, tx.From, 0); err != nil { + return 0, fmt.Errorf("UNSTAKE write: %w", err) + } + if err := c.creditBalance(txn, tx.From, staked); err != nil { + return 0, fmt.Errorf("UNSTAKE credit: %w", err) + } + log.Printf("[CHAIN] UNSTAKE pubkey=%s returned=%d µT", + tx.From[:min(8, len(tx.From))], staked) + + case EventIssueToken: + var p IssueTokenPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: ISSUE_TOKEN bad payload: %v", ErrTxFailed, err) + } + if p.Name == "" || p.Symbol == "" { + return 0, fmt.Errorf("%w: ISSUE_TOKEN: name and symbol are required", ErrTxFailed) + } + if p.TotalSupply == 0 { + return 0, fmt.Errorf("%w: ISSUE_TOKEN: total_supply must be > 0", ErrTxFailed) + } + if tx.Fee < MinIssueTokenFee { + return 0, fmt.Errorf("%w: ISSUE_TOKEN fee %d < MinIssueTokenFee %d", + ErrTxFailed, tx.Fee, MinIssueTokenFee) + } + tokenID := computeTokenID(tx.From, p.Symbol) + if _, dbErr := txn.Get([]byte(prefixToken + tokenID)); dbErr == nil { + return 0, fmt.Errorf("%w: ISSUE_TOKEN: token %s already exists", ErrTxFailed, tokenID) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("ISSUE_TOKEN debit: %w", err) + } + var height uint64 + if hi, hErr := txn.Get([]byte(prefixHeight)); hErr == nil { + _ = hi.Value(func(val []byte) error { return json.Unmarshal(val, &height) }) + } + tokenRec := TokenRecord{ + TokenID: tokenID, + Name: p.Name, + Symbol: p.Symbol, + Decimals: p.Decimals, + TotalSupply: p.TotalSupply, + Issuer: tx.From, + IssuedAt: height, + } + val, _ := json.Marshal(tokenRec) + if err := txn.Set([]byte(prefixToken+tokenID), val); err != nil { + return 0, fmt.Errorf("store token record: %w", err) + } + if err := c.creditTokenBalance(txn, tokenID, tx.From, p.TotalSupply); err != nil { + return 0, fmt.Errorf("ISSUE_TOKEN credit: %w", err) + } + log.Printf("[CHAIN] ISSUE_TOKEN id=%s symbol=%s supply=%d issuer=%s", + tokenID, p.Symbol, p.TotalSupply, tx.From[:min(8, len(tx.From))]) + + case EventTransferToken: + var p TransferTokenPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: TRANSFER_TOKEN bad payload: %v", ErrTxFailed, err) + } + if p.TokenID == "" || p.Amount == 0 { + return 0, fmt.Errorf("%w: TRANSFER_TOKEN: token_id and amount are required", ErrTxFailed) + } + if tx.To == "" { + return 0, fmt.Errorf("%w: TRANSFER_TOKEN: recipient (to) is required", ErrTxFailed) + } + if _, dbErr := txn.Get([]byte(prefixToken + p.TokenID)); dbErr != nil { + return 0, fmt.Errorf("%w: TRANSFER_TOKEN: token %s not found", ErrTxFailed, p.TokenID) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("TRANSFER_TOKEN fee debit: %w", err) + } + if err := c.debitTokenBalance(txn, p.TokenID, tx.From, p.Amount); err != nil { + return 0, fmt.Errorf("TRANSFER_TOKEN debit: %w", err) + } + if err := c.creditTokenBalance(txn, p.TokenID, tx.To, p.Amount); err != nil { + return 0, fmt.Errorf("TRANSFER_TOKEN credit: %w", err) + } + + case EventBurnToken: + var p BurnTokenPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: BURN_TOKEN bad payload: %v", ErrTxFailed, err) + } + if p.TokenID == "" || p.Amount == 0 { + return 0, fmt.Errorf("%w: BURN_TOKEN: token_id and amount are required", ErrTxFailed) + } + tokenItem, dbErr := txn.Get([]byte(prefixToken + p.TokenID)) + if dbErr != nil { + return 0, fmt.Errorf("%w: BURN_TOKEN: token %s not found", ErrTxFailed, p.TokenID) + } + var tokenRec TokenRecord + if err := tokenItem.Value(func(v []byte) error { return json.Unmarshal(v, &tokenRec) }); err != nil { + return 0, fmt.Errorf("%w: BURN_TOKEN: corrupt token record", ErrTxFailed) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("BURN_TOKEN fee debit: %w", err) + } + if err := c.debitTokenBalance(txn, p.TokenID, tx.From, p.Amount); err != nil { + return 0, fmt.Errorf("BURN_TOKEN debit: %w", err) + } + // Reduce total supply. + if tokenRec.TotalSupply >= p.Amount { + tokenRec.TotalSupply -= p.Amount + } else { + tokenRec.TotalSupply = 0 + } + val, _ := json.Marshal(tokenRec) + if err := txn.Set([]byte(prefixToken+p.TokenID), val); err != nil { + return 0, fmt.Errorf("BURN_TOKEN update supply: %w", err) + } + log.Printf("[CHAIN] BURN_TOKEN id=%s amount=%d newSupply=%d burner=%s", + p.TokenID, p.Amount, tokenRec.TotalSupply, tx.From[:min(8, len(tx.From))]) + + case EventMintNFT: + var p MintNFTPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: MINT_NFT bad payload: %v", ErrTxFailed, err) + } + if p.Name == "" { + return 0, fmt.Errorf("%w: MINT_NFT: name is required", ErrTxFailed) + } + if tx.Fee < MinMintNFTFee { + return 0, fmt.Errorf("%w: MINT_NFT fee %d < MinMintNFTFee %d", + ErrTxFailed, tx.Fee, MinMintNFTFee) + } + nftID := computeNFTID(tx.From, tx.ID) + if _, dbErr := txn.Get([]byte(prefixNFT + nftID)); dbErr == nil { + return 0, fmt.Errorf("%w: MINT_NFT: NFT %s already exists", ErrTxFailed, nftID) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("MINT_NFT debit: %w", err) + } + var height uint64 + if hi, hErr := txn.Get([]byte(prefixHeight)); hErr == nil { + _ = hi.Value(func(val []byte) error { return json.Unmarshal(val, &height) }) + } + nft := NFTRecord{ + NFTID: nftID, + Name: p.Name, + Description: p.Description, + URI: p.URI, + Attributes: p.Attributes, + Owner: tx.From, + Issuer: tx.From, + MintedAt: height, + } + val, _ := json.Marshal(nft) + if err := txn.Set([]byte(prefixNFT+nftID), val); err != nil { + return 0, fmt.Errorf("store NFT: %w", err) + } + if err := txn.Set([]byte(prefixNFTOwner+tx.From+":"+nftID), []byte{}); err != nil { + return 0, fmt.Errorf("index NFT owner: %w", err) + } + log.Printf("[CHAIN] MINT_NFT id=%s name=%q owner=%s", + nftID, p.Name, tx.From[:min(8, len(tx.From))]) + + case EventTransferNFT: + var p TransferNFTPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: TRANSFER_NFT bad payload: %v", ErrTxFailed, err) + } + if p.NFTID == "" { + return 0, fmt.Errorf("%w: TRANSFER_NFT: nft_id is required", ErrTxFailed) + } + if tx.To == "" { + return 0, fmt.Errorf("%w: TRANSFER_NFT: recipient (to) is required", ErrTxFailed) + } + nftItem, dbErr := txn.Get([]byte(prefixNFT + p.NFTID)) + if dbErr != nil { + return 0, fmt.Errorf("%w: TRANSFER_NFT: NFT %s not found", ErrTxFailed, p.NFTID) + } + var nft NFTRecord + if err := nftItem.Value(func(v []byte) error { return json.Unmarshal(v, &nft) }); err != nil { + return 0, fmt.Errorf("%w: TRANSFER_NFT: corrupt NFT record", ErrTxFailed) + } + if nft.Burned { + return 0, fmt.Errorf("%w: TRANSFER_NFT: NFT %s is burned", ErrTxFailed, p.NFTID) + } + if nft.Owner != tx.From { + return 0, fmt.Errorf("%w: TRANSFER_NFT: %s is not the owner of NFT %s", + ErrTxFailed, tx.From[:min(8, len(tx.From))], p.NFTID) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("TRANSFER_NFT fee debit: %w", err) + } + // Remove old owner index, add new one. + _ = txn.Delete([]byte(prefixNFTOwner + tx.From + ":" + p.NFTID)) + if err := txn.Set([]byte(prefixNFTOwner+tx.To+":"+p.NFTID), []byte{}); err != nil { + return 0, fmt.Errorf("index new NFT owner: %w", err) + } + nft.Owner = tx.To + val, _ := json.Marshal(nft) + if err := txn.Set([]byte(prefixNFT+p.NFTID), val); err != nil { + return 0, fmt.Errorf("update NFT owner: %w", err) + } + + case EventBurnNFT: + var p BurnNFTPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return 0, fmt.Errorf("%w: BURN_NFT bad payload: %v", ErrTxFailed, err) + } + if p.NFTID == "" { + return 0, fmt.Errorf("%w: BURN_NFT: nft_id is required", ErrTxFailed) + } + nftItem, dbErr := txn.Get([]byte(prefixNFT + p.NFTID)) + if dbErr != nil { + return 0, fmt.Errorf("%w: BURN_NFT: NFT %s not found", ErrTxFailed, p.NFTID) + } + var nft NFTRecord + if err := nftItem.Value(func(v []byte) error { return json.Unmarshal(v, &nft) }); err != nil { + return 0, fmt.Errorf("%w: BURN_NFT: corrupt NFT record", ErrTxFailed) + } + if nft.Owner != tx.From { + return 0, fmt.Errorf("%w: BURN_NFT: %s is not the owner", + ErrTxFailed, tx.From[:min(8, len(tx.From))]) + } + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("BURN_NFT fee debit: %w", err) + } + _ = txn.Delete([]byte(prefixNFTOwner + tx.From + ":" + p.NFTID)) + nft.Burned = true + nft.Owner = "" + val, _ := json.Marshal(nft) + if err := txn.Set([]byte(prefixNFT+p.NFTID), val); err != nil { + return 0, fmt.Errorf("BURN_NFT update: %w", err) + } + log.Printf("[CHAIN] BURN_NFT id=%s burner=%s", p.NFTID, tx.From[:min(8, len(tx.From))]) + + case EventBlockReward: + return 0, fmt.Errorf("%w: BLOCK_REWARD is a synthetic event and cannot be included in blocks", + ErrTxFailed) + + default: + // Forward-compatibility: a tx with an EventType this binary doesn't + // recognise is treated as a no-op rather than a hard error. This + // lets newer clients include newer event kinds in blocks without + // splitting the validator set every time a feature lands. + // + // Still charge the fee so the tx isn't a free spam vector: if an + // attacker sends bogus-type txs, they pay for each one like any + // other tx. The validator pockets the fee via the outer AddBlock + // loop (collectedFees += tx.Fee). + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return 0, fmt.Errorf("%w: unknown-type fee debit: %v", ErrTxFailed, err) + } + log.Printf("[CHAIN] unknown event type %q in tx %s — applied as no-op (binary is older than this tx)", + tx.Type, tx.ID) + } + return 0, nil +} + +func (c *Chain) applyOpenPayChan(txn *badger.Txn, tx *Transaction) error { + var p OpenPayChanPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return err + } + if p.ChannelID == "" || p.PartyA == "" || p.PartyB == "" { + return fmt.Errorf("missing channel fields") + } + if tx.From != p.PartyA { + return fmt.Errorf("tx.From must be PartyA") + } + if p.DepositA+p.DepositB == 0 { + return fmt.Errorf("at least one deposit must be > 0") + } + + // Verify PartyB's counter-signature over the channel parameters. + sigPayload := payChanSigPayload(p.ChannelID, p.PartyA, p.PartyB, p.DepositA, p.DepositB, p.ExpiryBlock) + if ok, err := verifyEd25519(p.PartyB, sigPayload, p.SigB); err != nil || !ok { + return fmt.Errorf("invalid PartyB signature") + } + + // Check channel does not already exist. + if _, existErr := txn.Get([]byte(prefixPayChan + p.ChannelID)); existErr == nil { + return fmt.Errorf("channel %s already exists", p.ChannelID) + } + + // Lock deposits. + if p.DepositA > 0 { + if err := c.debitBalance(txn, p.PartyA, p.DepositA+tx.Fee); err != nil { + return fmt.Errorf("debit PartyA: %w", err) + } + } else { + if err := c.debitBalance(txn, p.PartyA, tx.Fee); err != nil { + return fmt.Errorf("debit PartyA fee: %w", err) + } + } + if p.DepositB > 0 { + if err := c.debitBalance(txn, p.PartyB, p.DepositB); err != nil { + return fmt.Errorf("debit PartyB: %w", err) + } + } + + // Read current block height for OpenedBlock. + var height uint64 + item, err := txn.Get([]byte(prefixHeight)) + if err == nil { + _ = item.Value(func(val []byte) error { return json.Unmarshal(val, &height) }) + } + + state := PayChanState{ + ChannelID: p.ChannelID, + PartyA: p.PartyA, + PartyB: p.PartyB, + DepositA: p.DepositA, + DepositB: p.DepositB, + ExpiryBlock: p.ExpiryBlock, + OpenedBlock: height, + Nonce: 0, + } + val, err := json.Marshal(state) + if err != nil { + return err + } + return txn.Set([]byte(prefixPayChan+p.ChannelID), val) +} + +func (c *Chain) applyClosePayChan(txn *badger.Txn, tx *Transaction) error { + var p ClosePayChanPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil { + return err + } + + // Load channel state. + item, err := txn.Get([]byte(prefixPayChan + p.ChannelID)) + if err != nil { + return fmt.Errorf("channel %s not found", p.ChannelID) + } + var state PayChanState + if err := item.Value(func(val []byte) error { + return json.Unmarshal(val, &state) + }); err != nil { + return err + } + if state.Closed { + return fmt.Errorf("channel %s already closed", p.ChannelID) + } + if p.Nonce < state.Nonce { + return fmt.Errorf("stale state: nonce %d < current %d", p.Nonce, state.Nonce) + } + total := state.DepositA + state.DepositB + if p.BalanceA+p.BalanceB != total { + return fmt.Errorf("balance sum %d != total deposits %d", p.BalanceA+p.BalanceB, total) + } + // Verify both parties' signatures over the final state. + sigPayload := payChanCloseSigPayload(p.ChannelID, p.BalanceA, p.BalanceB, p.Nonce) + if okA, err := verifyEd25519(state.PartyA, sigPayload, p.SigA); err != nil || !okA { + return fmt.Errorf("invalid PartyA close signature") + } + if okB, err := verifyEd25519(state.PartyB, sigPayload, p.SigB); err != nil || !okB { + return fmt.Errorf("invalid PartyB close signature") + } + + // Distribute balances. + if p.BalanceA > 0 { + if err := c.creditBalance(txn, state.PartyA, p.BalanceA); err != nil { + return fmt.Errorf("credit PartyA: %w", err) + } + } + if p.BalanceB > 0 { + if err := c.creditBalance(txn, state.PartyB, p.BalanceB); err != nil { + return fmt.Errorf("credit PartyB: %w", err) + } + } + // Deduct fee from submitter. + if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil { + return fmt.Errorf("debit closer fee: %w", err) + } + + // Mark channel closed. + state.Closed = true + state.Nonce = p.Nonce + val, err := json.Marshal(state) + if err != nil { + return err + } + return txn.Set([]byte(prefixPayChan+p.ChannelID), val) +} + +// payChanSigPayload returns the bytes both parties sign when agreeing to open a channel. +func payChanSigPayload(channelID, partyA, partyB string, depositA, depositB, expiryBlock uint64) []byte { + data, _ := json.Marshal(struct { + ChannelID string `json:"channel_id"` + PartyA string `json:"party_a"` + PartyB string `json:"party_b"` + DepositA uint64 `json:"deposit_a_ut"` + DepositB uint64 `json:"deposit_b_ut"` + ExpiryBlock uint64 `json:"expiry_block"` + }{channelID, partyA, partyB, depositA, depositB, expiryBlock}) + return data +} + +// payChanCloseSigPayload returns the bytes both parties sign to close a channel. +func payChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64) []byte { + data, _ := json.Marshal(struct { + ChannelID string `json:"channel_id"` + BalanceA uint64 `json:"balance_a_ut"` + BalanceB uint64 `json:"balance_b_ut"` + Nonce uint64 `json:"nonce"` + }{channelID, balanceA, balanceB, nonce}) + return data +} + +// incrementRep reads, modifies, and writes a RepStats entry. +func (c *Chain) incrementRep(txn *badger.Txn, pubKeyHex string, fn func(*RepStats)) error { + key := []byte(prefixReputation + pubKeyHex) + var r RepStats + item, err := txn.Get(key) + if err != nil && !errors.Is(err, badger.ErrKeyNotFound) { + return err + } + if err == nil { + _ = item.Value(func(val []byte) error { + return json.Unmarshal(val, &r) + }) + } + fn(&r) + r.Score = r.ComputeScore() + val, err := json.Marshal(r) + if err != nil { + return err + } + return txn.Set(key, val) +} + +func (c *Chain) readBalance(txn *badger.Txn, pubKeyHex string) (uint64, error) { + item, err := txn.Get([]byte(prefixBalance + pubKeyHex)) + if errors.Is(err, badger.ErrKeyNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + var bal uint64 + err = item.Value(func(val []byte) error { + return json.Unmarshal(val, &bal) + }) + return bal, err +} + +func (c *Chain) writeBalance(txn *badger.Txn, pubKeyHex string, bal uint64) error { + val, err := json.Marshal(bal) + if err != nil { + return err + } + return txn.Set([]byte(prefixBalance+pubKeyHex), val) +} + +func (c *Chain) creditBalance(txn *badger.Txn, pubKeyHex string, amount uint64) error { + bal, err := c.readBalance(txn, pubKeyHex) + if err != nil { + return err + } + return c.writeBalance(txn, pubKeyHex, bal+amount) +} + +func (c *Chain) debitBalance(txn *badger.Txn, pubKeyHex string, amount uint64) error { + bal, err := c.readBalance(txn, pubKeyHex) + if err != nil { + return err // DB error — not ErrTxFailed + } + if bal < amount { + return fmt.Errorf("%w: insufficient balance for %s: have %d µT, need %d µT", + ErrTxFailed, pubKeyHex[:min(8, len(pubKeyHex))], bal, amount) + } + return c.writeBalance(txn, pubKeyHex, bal-amount) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// ── Stake helpers ───────────────────────────────────────────────────────────── + +func (c *Chain) readStake(txn *badger.Txn, pubKeyHex string) uint64 { + item, err := txn.Get([]byte(prefixStake + pubKeyHex)) + if err != nil { + return 0 + } + var amount uint64 + _ = item.Value(func(val []byte) error { return json.Unmarshal(val, &amount) }) + return amount +} + +func (c *Chain) writeStake(txn *badger.Txn, pubKeyHex string, amount uint64) error { + if amount == 0 { + err := txn.Delete([]byte(prefixStake + pubKeyHex)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + return err + } + val, _ := json.Marshal(amount) + return txn.Set([]byte(prefixStake+pubKeyHex), val) +} + +// Stake returns the staked amount for a public key (public query). +func (c *Chain) Stake(pubKeyHex string) (uint64, error) { + var amount uint64 + err := c.db.View(func(txn *badger.Txn) error { + amount = c.readStake(txn, pubKeyHex) + return nil + }) + return amount, err +} + +// ── Token balance helpers ────────────────────────────────────────────────────── + +func tokenBalKey(tokenID, pubKeyHex string) []byte { + return []byte(prefixTokenBal + tokenID + ":" + pubKeyHex) +} + +func (c *Chain) readTokenBalance(txn *badger.Txn, tokenID, pubKeyHex string) uint64 { + item, err := txn.Get(tokenBalKey(tokenID, pubKeyHex)) + if err != nil { + return 0 + } + var bal uint64 + _ = item.Value(func(val []byte) error { return json.Unmarshal(val, &bal) }) + return bal +} + +func (c *Chain) writeTokenBalance(txn *badger.Txn, tokenID, pubKeyHex string, bal uint64) error { + if bal == 0 { + err := txn.Delete(tokenBalKey(tokenID, pubKeyHex)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + return err + } + val, _ := json.Marshal(bal) + return txn.Set(tokenBalKey(tokenID, pubKeyHex), val) +} + +func (c *Chain) creditTokenBalance(txn *badger.Txn, tokenID, pubKeyHex string, amount uint64) error { + bal := c.readTokenBalance(txn, tokenID, pubKeyHex) + return c.writeTokenBalance(txn, tokenID, pubKeyHex, bal+amount) +} + +func (c *Chain) debitTokenBalance(txn *badger.Txn, tokenID, pubKeyHex string, amount uint64) error { + bal := c.readTokenBalance(txn, tokenID, pubKeyHex) + if bal < amount { + return fmt.Errorf("%w: insufficient token balance for %s: have %d, need %d", + ErrTxFailed, pubKeyHex[:min(8, len(pubKeyHex))], bal, amount) + } + return c.writeTokenBalance(txn, tokenID, pubKeyHex, bal-amount) +} + +// TokenBalance returns the token balance for a public key (public query). +func (c *Chain) TokenBalance(tokenID, pubKeyHex string) (uint64, error) { + var bal uint64 + err := c.db.View(func(txn *badger.Txn) error { + bal = c.readTokenBalance(txn, tokenID, pubKeyHex) + return nil + }) + return bal, err +} + +// Token returns a TokenRecord by ID. +func (c *Chain) Token(tokenID string) (*TokenRecord, error) { + var rec TokenRecord + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixToken + tokenID)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }) + }) + if err != nil || rec.TokenID == "" { + return nil, err + } + return &rec, nil +} + +// Tokens returns all issued tokens. +func (c *Chain) Tokens() ([]TokenRecord, error) { + var out []TokenRecord + err := c.db.View(func(txn *badger.Txn) error { + prefix := []byte(prefixToken) + opts := badger.DefaultIteratorOptions + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + var rec TokenRecord + if err := it.Item().Value(func(val []byte) error { + return json.Unmarshal(val, &rec) + }); err == nil { + out = append(out, rec) + } + } + return nil + }) + return out, err +} + +// computeTokenID derives a deterministic token ID from issuer pubkey + symbol. +func computeTokenID(issuerPub, symbol string) string { + h := sha256.Sum256([]byte("token:" + issuerPub + ":" + symbol)) + return hex.EncodeToString(h[:16]) +} + +// computeNFTID derives a deterministic NFT ID from minter pubkey + tx ID. +func computeNFTID(minterPub, txID string) string { + h := sha256.Sum256([]byte("nft:" + minterPub + ":" + txID)) + return hex.EncodeToString(h[:16]) +} + +// NFT returns an NFTRecord by ID. +func (c *Chain) NFT(nftID string) (*NFTRecord, error) { + var rec NFTRecord + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixNFT + nftID)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }) + }) + if err != nil || rec.NFTID == "" { + return nil, err + } + return &rec, nil +} + +// NFTsByOwner returns all NFTs owned by a public key (excluding burned). +func (c *Chain) NFTsByOwner(ownerPub string) ([]NFTRecord, error) { + var out []NFTRecord + err := c.db.View(func(txn *badger.Txn) error { + prefix := []byte(prefixNFTOwner + ownerPub + ":") + opts := badger.DefaultIteratorOptions + opts.Prefix = prefix + opts.PrefetchValues = false + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + key := string(it.Item().Key()) + nftID := strings.TrimPrefix(key, prefixNFTOwner+ownerPub+":") + item, err := txn.Get([]byte(prefixNFT + nftID)) + if err != nil { + continue + } + var rec NFTRecord + if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err == nil && !rec.Burned { + out = append(out, rec) + } + } + return nil + }) + return out, err +} + +// NFTs returns all minted NFTs (including burned for history). +func (c *Chain) NFTs() ([]NFTRecord, error) { + var out []NFTRecord + err := c.db.View(func(txn *badger.Txn) error { + prefix := []byte(prefixNFT) + opts := badger.DefaultIteratorOptions + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + var rec NFTRecord + if err := it.Item().Value(func(val []byte) error { + return json.Unmarshal(val, &rec) + }); err == nil { + out = append(out, rec) + } + } + return nil + }) + return out, err +} + +// RegisteredRelayInfo wraps a relay's Ed25519 pub key with its registration payload. +type RegisteredRelayInfo struct { + PubKey string `json:"pub_key"` + Address string `json:"address"` + Relay RegisterRelayPayload `json:"relay"` + LastHeartbeat int64 `json:"last_heartbeat,omitempty"` // unix seconds +} + +// writeRelayHeartbeat stores the given unix timestamp as the relay's +// last-heartbeat marker. Called from REGISTER_RELAY and from HEARTBEAT +// txs originating from registered relays. +func (c *Chain) writeRelayHeartbeat(txn *badger.Txn, nodePub string, unixSec int64) error { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], uint64(unixSec)) + return txn.Set([]byte(prefixRelayHB+nodePub), buf[:]) +} + +// readRelayHeartbeat returns the stored unix seconds, or 0 if missing. +func (c *Chain) readRelayHeartbeat(txn *badger.Txn, nodePub string) int64 { + item, err := txn.Get([]byte(prefixRelayHB + nodePub)) + if err != nil { + return 0 + } + var out int64 + _ = item.Value(func(val []byte) error { + if len(val) != 8 { + return nil + } + out = int64(binary.BigEndian.Uint64(val)) + return nil + }) + return out +} + +// RegisteredRelays returns every relay node that has submitted EventRegisterRelay +// AND whose last heartbeat is within RelayHeartbeatTTL. Dead relays (node +// died, network went away) are filtered out so clients don't waste +// bandwidth trying to deliver through them. +// +// A relay without any recorded heartbeat is treated as live — this covers +// historical data from nodes upgraded to this version; they'll start +// recording heartbeats on the next HEARTBEAT tx. +func (c *Chain) RegisteredRelays() ([]RegisteredRelayInfo, error) { + var out []RegisteredRelayInfo + now := time.Now().Unix() + err := c.db.View(func(txn *badger.Txn) error { + prefix := []byte(prefixRelay) + opts := badger.DefaultIteratorOptions + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + key := string(item.Key()) + pubKey := key[len(prefixRelay):] + var p RegisterRelayPayload + if err := item.Value(func(val []byte) error { + return json.Unmarshal(val, &p) + }); err != nil { + continue + } + hb := c.readRelayHeartbeat(txn, pubKey) + if hb > 0 && now-hb > RelayHeartbeatTTL { + continue // stale, delist + } + out = append(out, RegisteredRelayInfo{ + PubKey: pubKey, + Address: pubKeyToAddr(pubKey), + Relay: p, + LastHeartbeat: hb, + }) + } + return nil + }) + return out, err +} + +// contactRecord is the on-chain storage representation of a contact relationship. +type contactRecord struct { + Status string `json:"status"` + Intro string `json:"intro,omitempty"` + FeeUT uint64 `json:"fee_ut"` + TxID string `json:"tx_id"` + CreatedAt int64 `json:"created_at"` +} + +// updateContactStatus updates the status of an existing contact record. +// Only Pending records may be transitioned — re-accepting an already-accepted +// or blocked request is a no-op error so an attacker cannot spam state changes +// on another user's contact list by replaying ACCEPT_CONTACT txs. +func (c *Chain) updateContactStatus(txn *badger.Txn, key string, status ContactStatus) error { + item, err := txn.Get([]byte(key)) + if err != nil { + return fmt.Errorf("contact record not found") + } + var rec contactRecord + if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil { + return err + } + // Allowed transitions: + // Pending -> Accepted (ACCEPT_CONTACT) + // Pending -> Blocked (BLOCK_CONTACT) + // Accepted -> Blocked (BLOCK_CONTACT, recipient changes their mind) + // Everything else is rejected. + cur := ContactStatus(rec.Status) + switch status { + case ContactAccepted: + if cur != ContactPending { + return fmt.Errorf("cannot accept contact: current status is %q, must be %q", + cur, ContactPending) + } + case ContactBlocked: + if cur != ContactPending && cur != ContactAccepted { + return fmt.Errorf("cannot block contact: current status is %q", cur) + } + } + rec.Status = string(status) + val, _ := json.Marshal(rec) + return txn.Set([]byte(key), val) +} + +// ContactRequests returns all incoming contact records for the given Ed25519 pubkey. +// Results include pending, accepted, and blocked records. +func (c *Chain) ContactRequests(targetPub string) ([]ContactInfo, error) { + prefix := []byte(prefixContactIn + targetPub + ":") + var out []ContactInfo + err := c.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + key := string(item.Key()) + // key format: contact_in:: + requesterPub := key[len(prefixContactIn)+len(targetPub)+1:] + var rec contactRecord + if err := item.Value(func(val []byte) error { + return json.Unmarshal(val, &rec) + }); err != nil { + continue + } + out = append(out, ContactInfo{ + RequesterPub: requesterPub, + RequesterAddr: pubKeyToAddr(requesterPub), + Status: ContactStatus(rec.Status), + Intro: rec.Intro, + FeeUT: rec.FeeUT, + TxID: rec.TxID, + CreatedAt: rec.CreatedAt, + }) + } + return nil + }) + return out, err +} + +// IdentityInfo returns identity information for the given Ed25519 public key. +// It works even if the key has never submitted a REGISTER_KEY transaction. +func (c *Chain) IdentityInfo(pubKey string) (*IdentityInfo, error) { + info := &IdentityInfo{ + PubKey: pubKey, + Address: pubKeyToAddr(pubKey), + } + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixIdentity + pubKey)) + if err != nil { + return nil // not registered — return defaults + } + return item.Value(func(val []byte) error { + var p RegisterKeyPayload + if err := json.Unmarshal(val, &p); err != nil { + return err + } + info.Registered = true + info.Nickname = p.Nickname + info.X25519Pub = p.X25519PubKey + return nil + }) + }) + return info, err +} + +// InitValidators replaces the on-chain validator set with the given pub keys. +// Any stale keys from previous runs are deleted first so that old Docker +// volumes or leftover DB state can never inject phantom validators into PBFT. +// Dynamic ADD_VALIDATOR / REMOVE_VALIDATOR transactions layer on top of this +// base set for the lifetime of the running node. +func (c *Chain) InitValidators(pubKeys []string) error { + return c.db.Update(func(txn *badger.Txn) error { + // Collect and delete all existing validator keys. + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + prefix := []byte(prefixValidator) + it := txn.NewIterator(opts) + var toDelete [][]byte + for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { + k := make([]byte, len(it.Item().Key())) + copy(k, it.Item().Key()) + toDelete = append(toDelete, k) + } + it.Close() + for _, k := range toDelete { + if err := txn.Delete(k); err != nil { + return err + } + } + // Write the authoritative set from CLI flags. + for _, pk := range pubKeys { + if err := txn.Set([]byte(prefixValidator+pk), []byte{}); err != nil { + return err + } + } + return nil + }) +} + +// ValidatorSet returns the current active validator pub keys (sorted by insertion order). +func (c *Chain) ValidatorSet() ([]string, error) { + var validators []string + err := c.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + it := txn.NewIterator(opts) + defer it.Close() + prefix := []byte(prefixValidator) + for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { + key := string(it.Item().Key()) + validators = append(validators, key[len(prefixValidator):]) + } + return nil + }) + return validators, err +} + +// validatorSetTxn returns the full current validator set inside the given +// txn. Used by ADD_VALIDATOR to compute the ⌈2/3⌉ approval threshold. +// Ordering is iteration order (BadgerDB key order), stable enough for +// threshold math. +func (c *Chain) validatorSetTxn(txn *badger.Txn) ([]string, error) { + prefix := []byte(prefixValidator) + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + var out []string + for it.Rewind(); it.Valid(); it.Next() { + out = append(out, string(it.Item().Key()[len(prefix):])) + } + return out, nil +} + +// contains is a tiny generic-free helper: true if s is in haystack. +func contains(haystack []string, s string) bool { + for _, h := range haystack { + if h == s { + return true + } + } + return false +} + +// isValidatorTxn checks if pubKey is an active validator inside a read/write txn. +func (c *Chain) isValidatorTxn(txn *badger.Txn, pubKey string) (bool, error) { + _, err := txn.Get([]byte(prefixValidator + pubKey)) + if err == badger.ErrKeyNotFound { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// verifyEd25519 verifies an Ed25519 signature without importing the identity package +// (which would create a circular dependency). +func verifyEd25519(pubKeyHex string, msg, sig []byte) (bool, error) { + pubBytes, err := hex.DecodeString(pubKeyHex) + if err != nil { + return false, fmt.Errorf("invalid pub key hex: %w", err) + } + return ed25519.Verify(ed25519.PublicKey(pubBytes), msg, sig), nil +} + +// --- contract helpers --- + +// computeContractID returns hex(sha256(deployerPub || wasmBytes)[:16]). +// Stable and deterministic: same deployer + same WASM → same contract ID. +func computeContractID(deployerPub string, wasmBytes []byte) string { + import256 := sha256.New() + import256.Write([]byte(deployerPub)) + import256.Write(wasmBytes) + return hex.EncodeToString(import256.Sum(nil)[:16]) +} + +// decodeBase64 decodes a standard or raw base64 string into dst. +// Returns the number of bytes written. +func decodeBase64(s string, dst []byte) (int, error) { + import64 := base64.StdEncoding + // Try standard encoding first, then raw (no padding). + n, err := import64.Decode(dst, []byte(s)) + if err != nil { + n, err = base64.RawStdEncoding.Decode(dst, []byte(s)) + } + return n, err +} + +// Contracts returns all deployed contracts (WASM bytes omitted). +func (c *Chain) Contracts() ([]ContractRecord, error) { + var out []ContractRecord + err := c.db.View(func(txn *badger.Txn) error { + prefix := []byte(prefixContract) + opts := badger.DefaultIteratorOptions + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + var rec ContractRecord + if err := it.Item().Value(func(v []byte) error { + return json.Unmarshal(v, &rec) + }); err != nil { + continue + } + rec.WASMBytes = nil // strip bytes from list response + out = append(out, rec) + } + return nil + }) + return out, err +} + +// GetContract returns the ContractRecord for the given contract ID, or nil if not found. +func (c *Chain) GetContract(contractID string) (*ContractRecord, error) { + var rec ContractRecord + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixContract + contractID)) + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &rec) + }) + }) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, nil + } + return &rec, err +} + +// GetContractState returns the raw state value for a contract key, or nil if not set. +func (c *Chain) GetContractState(contractID, key string) ([]byte, error) { + var result []byte + err := c.db.View(func(txn *badger.Txn) error { + dbKey := []byte(prefixContractState + contractID + ":" + key) + item, err := txn.Get(dbKey) + if err != nil { + return err + } + return item.Value(func(val []byte) error { + result = make([]byte, len(val)) + copy(result, val) + return nil + }) + }) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, nil + } + return result, err +} + +// ContractLogs returns the most recent log entries for a contract, newest first. +// limit <= 0 returns up to 100 entries. +func (c *Chain) ContractLogs(contractID string, limit int) ([]ContractLogEntry, error) { + if limit <= 0 || limit > 100 { + limit = 100 + } + prefix := []byte(prefixContractLog + contractID + ":") + var entries []ContractLogEntry + + err := c.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.Reverse = true + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + // Seek to the end of the prefix range (prefix + 0xFF). + seekKey := append(append([]byte{}, prefix...), 0xFF) + for it.Seek(seekKey); it.ValidForPrefix(prefix) && len(entries) < limit; it.Next() { + var entry ContractLogEntry + if err := it.Item().Value(func(val []byte) error { + return json.Unmarshal(val, &entry) + }); err == nil { + entries = append(entries, entry) + } + } + return nil + }) + return entries, err +} + +// maxContractCallDepth is the maximum nesting depth for inter-contract calls. +// Prevents infinite recursion and stack overflows. +const maxContractCallDepth = 8 + +// chainHostEnv implements VMHostEnv backed by a live badger.Txn. +type chainHostEnv struct { + txn *badger.Txn + contractID string + caller string + blockHeight uint64 + txID string + logSeq int + chain *Chain + depth int // inter-contract call nesting depth (0 = top-level) +} + +func newChainHostEnv(txn *badger.Txn, contractID, caller, txID string, blockHeight uint64, chain *Chain) *chainHostEnv { + return newChainHostEnvDepth(txn, contractID, caller, txID, blockHeight, chain, 0) +} + +func newChainHostEnvDepth(txn *badger.Txn, contractID, caller, txID string, blockHeight uint64, chain *Chain, depth int) *chainHostEnv { + // Count existing log entries for this contract+block so that logs from + // multiple TXs within the same block get unique sequence numbers and don't + // overwrite each other. + startSeq := countContractLogsInBlock(txn, contractID, blockHeight) + return &chainHostEnv{ + txn: txn, + contractID: contractID, + caller: caller, + txID: txID, + blockHeight: blockHeight, + chain: chain, + logSeq: startSeq, + depth: depth, + } +} + +// countContractLogsInBlock counts how many log entries already exist for the +// given contract at the given block height (used to pick the starting logSeq). +func countContractLogsInBlock(txn *badger.Txn, contractID string, blockHeight uint64) int { + prefix := []byte(fmt.Sprintf("%s%s:%020d:", prefixContractLog, contractID, blockHeight)) + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + n := 0 + for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { + n++ + } + return n +} + +func (e *chainHostEnv) GetState(key []byte) ([]byte, error) { + dbKey := []byte(prefixContractState + e.contractID + ":" + string(key)) + item, err := e.txn.Get(dbKey) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + var val []byte + err = item.Value(func(v []byte) error { + val = make([]byte, len(v)) + copy(val, v) + return nil + }) + return val, err +} + +func (e *chainHostEnv) SetState(key, value []byte) error { + dbKey := []byte(prefixContractState + e.contractID + ":" + string(key)) + return e.txn.Set(dbKey, value) +} + +func (e *chainHostEnv) GetBalance(pubKeyHex string) (uint64, error) { + return e.chain.readBalance(e.txn, pubKeyHex) +} + +func (e *chainHostEnv) Transfer(from, to string, amount uint64) error { + if err := e.chain.debitBalance(e.txn, from, amount); err != nil { + return err + } + return e.chain.creditBalance(e.txn, to, amount) +} + +func (e *chainHostEnv) GetCaller() string { return e.caller } +func (e *chainHostEnv) GetBlockHeight() uint64 { return e.blockHeight } + +// GetContractTreasury returns a deterministic ownerless address for this +// contract derived as hex(sha256(contractID + ":treasury")). +// No private key exists for this address; only the contract itself can spend +// from it via the transfer host function. +func (e *chainHostEnv) GetContractTreasury() string { + h := sha256.Sum256([]byte(e.contractID + ":treasury")) + return hex.EncodeToString(h[:]) +} +func (e *chainHostEnv) Log(msg string) { + log.Printf("[CONTRACT %s] %s", e.contractID[:8], msg) + entry := ContractLogEntry{ + ContractID: e.contractID, + BlockHeight: e.blockHeight, + TxID: e.txID, + Seq: e.logSeq, + Message: msg, + } + val, _ := json.Marshal(entry) + // Key: clog::: + key := fmt.Sprintf("%s%s:%020d:%05d", prefixContractLog, e.contractID, e.blockHeight, e.logSeq) + _ = e.txn.Set([]byte(key), val) + e.logSeq++ +} + +// CallContract executes a method on another deployed contract from within +// the current contract execution. The caller seen by the sub-contract is +// the current contract's ID. State changes share the same badger.Txn so +// they are all committed or rolled back atomically with the parent call. +func (e *chainHostEnv) CallContract(contractID, method string, argsJSON []byte, gasLimit uint64) (uint64, error) { + if e.depth >= maxContractCallDepth { + return 0, fmt.Errorf("%w: inter-contract call depth limit (%d) exceeded", + ErrTxFailed, maxContractCallDepth) + } + if e.chain.vm == nil { + return 0, fmt.Errorf("%w: VM not available for inter-contract call", ErrTxFailed) + } + item, err := e.txn.Get([]byte(prefixContract + contractID)) + if err != nil { + if errors.Is(err, badger.ErrKeyNotFound) { + return 0, fmt.Errorf("%w: contract %s not found", ErrTxFailed, contractID) + } + return 0, err + } + var rec ContractRecord + if err := item.Value(func(val []byte) error { return json.Unmarshal(val, &rec) }); err != nil { + return 0, fmt.Errorf("%w: corrupt contract record for inter-contract call: %v", ErrTxFailed, err) + } + // Sub-contract sees the current contract as its caller. + subEnv := newChainHostEnvDepth(e.txn, contractID, e.contractID, e.txID, e.blockHeight, e.chain, e.depth+1) + // Same timeout guard as the top-level CALL_CONTRACT path; protects + // against recursive contract calls that never return. + subCtx, subCancel := context.WithTimeout(context.Background(), 30*time.Second) + gasUsed, callErr := e.chain.vm.Call( + subCtx, + contractID, rec.WASMBytes, + method, argsJSON, + gasLimit, subEnv, + ) + subCancel() + if callErr != nil { + return gasUsed, fmt.Errorf("%w: sub-call %s.%s: %v", ErrTxFailed, contractID[:min(8, len(contractID))], method, callErr) + } + log.Printf("[CHAIN] inter-contract %s→%s.%s gasUsed=%d", + e.contractID[:min(8, len(e.contractID))], contractID[:min(8, len(contractID))], method, gasUsed) + return gasUsed, nil +} diff --git a/blockchain/chain_test.go b/blockchain/chain_test.go new file mode 100644 index 0000000..3a9180c --- /dev/null +++ b/blockchain/chain_test.go @@ -0,0 +1,796 @@ +package blockchain_test + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "go-blockchain/blockchain" + "go-blockchain/identity" +) + +// ─── helpers ──────────────────────────────────────────────────────────────── + +// newChain opens a fresh BadgerDB-backed chain in a temp directory and +// registers a cleanup that closes the DB then removes the directory. +// We avoid t.TempDir() because on Windows, BadgerDB's mmap'd value-log files +// may still be held open for a brief moment after Close() returns, causing +// the automatic TempDir cleanup to fail with "directory not empty". +// Using os.MkdirTemp + a retry loop works around this race. +func newChain(t *testing.T) *blockchain.Chain { + t.Helper() + dir, err := os.MkdirTemp("", "dchain-test-*") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + c, err := blockchain.NewChain(dir) + if err != nil { + _ = os.RemoveAll(dir) + t.Fatalf("NewChain: %v", err) + } + t.Cleanup(func() { + _ = c.Close() + // Retry removal to handle Windows mmap handle release delay. + for i := 0; i < 20; i++ { + if err := os.RemoveAll(dir); err == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + }) + return c +} + +// newIdentity generates a fresh Ed25519 + X25519 keypair for test use. +func newIdentity(t *testing.T) *identity.Identity { + t.Helper() + id, err := identity.Generate() + if err != nil { + t.Fatalf("identity.Generate: %v", err) + } + return id +} + +// addGenesis creates and commits the genesis block signed by validator. +func addGenesis(t *testing.T, c *blockchain.Chain, validator *identity.Identity) *blockchain.Block { + t.Helper() + b := blockchain.GenesisBlock(validator.PubKeyHex(), validator.PrivKey) + if err := c.AddBlock(b); err != nil { + t.Fatalf("AddBlock(genesis): %v", err) + } + return b +} + +// txID produces a short deterministic transaction ID. +func txID(from string, typ blockchain.EventType) string { + h := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", from, typ, time.Now().UnixNano()))) + return hex.EncodeToString(h[:16]) +} + +// makeTx builds a minimal transaction with all required fields set. +// Signature is intentionally left nil — chain.applyTx does not re-verify +// Ed25519 tx signatures (that is the consensus engine's job). +func makeTx(typ blockchain.EventType, from, to string, amount, fee uint64, payload []byte) *blockchain.Transaction { + return &blockchain.Transaction{ + ID: txID(from, typ), + Type: typ, + From: from, + To: to, + Amount: amount, + Fee: fee, + Payload: payload, + Timestamp: time.Now().UTC(), + } +} + +// mustJSON marshals v and panics on error (test helper only). +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} + +// buildBlock wraps txs in a block that follows prev, computes hash, and signs +// it with validatorPriv. TotalFees is computed from the tx slice. +func buildBlock(t *testing.T, prev *blockchain.Block, validator *identity.Identity, txs []*blockchain.Transaction) *blockchain.Block { + t.Helper() + var totalFees uint64 + for _, tx := range txs { + totalFees += tx.Fee + } + b := &blockchain.Block{ + Index: prev.Index + 1, + Timestamp: time.Now().UTC(), + Transactions: txs, + PrevHash: prev.Hash, + Validator: validator.PubKeyHex(), + TotalFees: totalFees, + } + b.ComputeHash() + b.Sign(validator.PrivKey) + return b +} + +// mustAddBlock calls c.AddBlock and fails the test on error. +func mustAddBlock(t *testing.T, c *blockchain.Chain, b *blockchain.Block) { + t.Helper() + if err := c.AddBlock(b); err != nil { + t.Fatalf("AddBlock (index %d): %v", b.Index, err) + } +} + +// mustBalance reads the balance and fails on error. +func mustBalance(t *testing.T, c *blockchain.Chain, pubHex string) uint64 { + t.Helper() + bal, err := c.Balance(pubHex) + if err != nil { + t.Fatalf("Balance(%s): %v", pubHex[:8], err) + } + return bal +} + +// ─── tests ─────────────────────────────────────────────────────────────────── + +// 1. Genesis block credits GenesisAllocation to the validator. +func TestGenesisCreatesBalance(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + + addGenesis(t, c, val) + + bal := mustBalance(t, c, val.PubKeyHex()) + if bal != blockchain.GenesisAllocation { + t.Errorf("expected GenesisAllocation=%d, got %d", blockchain.GenesisAllocation, bal) + } +} + +// 2. Transfer moves tokens between two identities and leaves correct balances. +func TestTransfer(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + alice := newIdentity(t) + + genesis := addGenesis(t, c, val) + + // Fund alice via a transfer from validator. + const sendAmount = 100 * blockchain.Token + const fee = blockchain.MinFee + + tx := makeTx( + blockchain.EventTransfer, + val.PubKeyHex(), + alice.PubKeyHex(), + sendAmount, fee, + mustJSON(blockchain.TransferPayload{}), + ) + + b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{tx}) + mustAddBlock(t, c, b1) + + valBal := mustBalance(t, c, val.PubKeyHex()) + aliceBal := mustBalance(t, c, alice.PubKeyHex()) + + // Validator: genesis - sendAmount - fee + fee (validator earns TotalFees back) + expectedVal := blockchain.GenesisAllocation - sendAmount - fee + fee + if valBal != expectedVal { + t.Errorf("validator balance: got %d, want %d", valBal, expectedVal) + } + if aliceBal != sendAmount { + t.Errorf("alice balance: got %d, want %d", aliceBal, sendAmount) + } +} + +// 3. Transfer that exceeds sender's balance must fail AddBlock. +func TestTransferInsufficientFunds(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + alice := newIdentity(t) + + genesis := addGenesis(t, c, val) + + // alice has 0 balance — try to spend 1 token + tx := makeTx( + blockchain.EventTransfer, + alice.PubKeyHex(), + val.PubKeyHex(), + 1*blockchain.Token, blockchain.MinFee, + mustJSON(blockchain.TransferPayload{}), + ) + b := buildBlock(t, genesis, val, []*blockchain.Transaction{tx}) + + // AddBlock must succeed — the bad tx is skipped rather than rejecting the block. + if err := c.AddBlock(b); err != nil { + t.Fatalf("AddBlock returned unexpected error: %v", err) + } + // Alice's balance must still be 0 — the skipped tx had no effect. + bal, err := c.Balance(alice.PubKeyHex()) + if err != nil { + t.Fatalf("Balance: %v", err) + } + if bal != 0 { + t.Errorf("expected alice balance 0, got %d", bal) + } +} + +// 4. EventRegisterKey stores X25519 key in IdentityInfo. +func TestRegisterKeyStoresIdentity(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + alice := newIdentity(t) + + genesis := addGenesis(t, c, val) + + payload := blockchain.RegisterKeyPayload{ + PubKey: alice.PubKeyHex(), + Nickname: "alice", + PowNonce: 0, + PowTarget: "0", + X25519PubKey: alice.X25519PubHex(), + } + tx := makeTx( + blockchain.EventRegisterKey, + alice.PubKeyHex(), + "", + 0, blockchain.RegistrationFee, + mustJSON(payload), + ) + + // Fund alice with enough to cover RegistrationFee before she registers. + fundTx := makeTx( + blockchain.EventTransfer, + val.PubKeyHex(), + alice.PubKeyHex(), + blockchain.RegistrationFee, blockchain.MinFee, + mustJSON(blockchain.TransferPayload{}), + ) + b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx}) + mustAddBlock(t, c, b1) + + b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx}) + mustAddBlock(t, c, b2) + + info, err := c.IdentityInfo(alice.PubKeyHex()) + if err != nil { + t.Fatalf("IdentityInfo: %v", err) + } + if !info.Registered { + t.Error("expected Registered=true after REGISTER_KEY tx") + } + if info.Nickname != "alice" { + t.Errorf("nickname: got %q, want %q", info.Nickname, "alice") + } + if info.X25519Pub != alice.X25519PubHex() { + t.Errorf("X25519Pub: got %q, want %q", info.X25519Pub, alice.X25519PubHex()) + } +} + +// 5. ContactRequest flow: pending → accepted → blocked. +func TestContactRequestFlow(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + alice := newIdentity(t) // requester + bob := newIdentity(t) // target + + genesis := addGenesis(t, c, val) + + // Fund alice and bob for fees. + const contactAmt = blockchain.MinContactFee + fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(), + contactAmt+2*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) + fundBob := makeTx(blockchain.EventTransfer, val.PubKeyHex(), bob.PubKeyHex(), + 2*blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) + + b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice, fundBob}) + mustAddBlock(t, c, b1) + + // Alice sends contact request to Bob. + reqTx := makeTx( + blockchain.EventContactRequest, + alice.PubKeyHex(), + bob.PubKeyHex(), + contactAmt, blockchain.MinFee, + mustJSON(blockchain.ContactRequestPayload{Intro: "Hey Bob!"}), + ) + b2 := buildBlock(t, b1, val, []*blockchain.Transaction{reqTx}) + mustAddBlock(t, c, b2) + + contacts, err := c.ContactRequests(bob.PubKeyHex()) + if err != nil { + t.Fatalf("ContactRequests: %v", err) + } + if len(contacts) != 1 { + t.Fatalf("expected 1 contact record, got %d", len(contacts)) + } + if contacts[0].Status != blockchain.ContactPending { + t.Errorf("status: got %q, want %q", contacts[0].Status, blockchain.ContactPending) + } + + // Bob accepts. + acceptTx := makeTx( + blockchain.EventAcceptContact, + bob.PubKeyHex(), + alice.PubKeyHex(), + 0, blockchain.MinFee, + mustJSON(blockchain.AcceptContactPayload{}), + ) + b3 := buildBlock(t, b2, val, []*blockchain.Transaction{acceptTx}) + mustAddBlock(t, c, b3) + + contacts, err = c.ContactRequests(bob.PubKeyHex()) + if err != nil { + t.Fatalf("ContactRequests after accept: %v", err) + } + if len(contacts) != 1 || contacts[0].Status != blockchain.ContactAccepted { + t.Errorf("expected accepted, got %v", contacts) + } + + // Bob then blocks Alice (status transitions from accepted → blocked). + blockTx := makeTx( + blockchain.EventBlockContact, + bob.PubKeyHex(), + alice.PubKeyHex(), + 0, blockchain.MinFee, + mustJSON(blockchain.BlockContactPayload{}), + ) + b4 := buildBlock(t, b3, val, []*blockchain.Transaction{blockTx}) + mustAddBlock(t, c, b4) + + contacts, err = c.ContactRequests(bob.PubKeyHex()) + if err != nil { + t.Fatalf("ContactRequests after block: %v", err) + } + if len(contacts) != 1 || contacts[0].Status != blockchain.ContactBlocked { + t.Errorf("expected blocked, got %v", contacts) + } +} + +// 6. ContactRequest with amount below MinContactFee must fail. +func TestContactRequestInsufficientFee(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + alice := newIdentity(t) + bob := newIdentity(t) + + genesis := addGenesis(t, c, val) + + // Fund alice. + fundAlice := makeTx(blockchain.EventTransfer, val.PubKeyHex(), alice.PubKeyHex(), + blockchain.MinContactFee+blockchain.MinFee, blockchain.MinFee, + mustJSON(blockchain.TransferPayload{})) + b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundAlice}) + mustAddBlock(t, c, b1) + + // Amount is one µT below MinContactFee. + reqTx := makeTx( + blockchain.EventContactRequest, + alice.PubKeyHex(), + bob.PubKeyHex(), + blockchain.MinContactFee-1, blockchain.MinFee, + mustJSON(blockchain.ContactRequestPayload{}), + ) + b := buildBlock(t, b1, val, []*blockchain.Transaction{reqTx}) + // AddBlock must succeed — the bad tx is skipped rather than rejecting the block. + if err := c.AddBlock(b); err != nil { + t.Fatalf("AddBlock returned unexpected error: %v", err) + } + // No pending contact record must exist for bob←alice. + contacts, err := c.ContactRequests(bob.PubKeyHex()) + if err != nil { + t.Fatalf("ContactRequests: %v", err) + } + if len(contacts) != 0 { + t.Errorf("expected 0 pending contacts, got %d (tx should have been skipped)", len(contacts)) + } +} + +// 7. InitValidators seeds keys; ValidatorSet returns them all. +func TestValidatorSetInit(t *testing.T) { + c := newChain(t) + ids := []*identity.Identity{newIdentity(t), newIdentity(t), newIdentity(t)} + keys := make([]string, len(ids)) + for i, id := range ids { + keys[i] = id.PubKeyHex() + } + + if err := c.InitValidators(keys); err != nil { + t.Fatalf("InitValidators: %v", err) + } + + set, err := c.ValidatorSet() + if err != nil { + t.Fatalf("ValidatorSet: %v", err) + } + if len(set) != len(keys) { + t.Fatalf("expected %d validators, got %d", len(keys), len(set)) + } + got := make(map[string]bool, len(set)) + for _, k := range set { + got[k] = true + } + for _, k := range keys { + if !got[k] { + t.Errorf("key %s missing from validator set", k[:8]) + } + } +} + +// 8. EventAddValidator adds a new validator via a real block. +// +// Updated for P2.1 (stake-gated admission): the candidate must first have +// at least MinValidatorStake (1 T = 1_000_000 µT) locked via a STAKE tx +// and be credited enough balance to do so. Multi-sig approval is trivially +// met here because the initial set has only one validator — ⌈2/3⌉ of 1 +// is 1, which the tx sender provides implicitly. +func TestAddValidatorTx(t *testing.T) { + c := newChain(t) + val := newIdentity(t) // initial validator + newVal := newIdentity(t) // to be added + + // Seed the initial validator. + if err := c.InitValidators([]string{val.PubKeyHex()}); err != nil { + t.Fatalf("InitValidators: %v", err) + } + + genesis := addGenesis(t, c, val) + + // Fund the candidate enough to stake. + fundTx := makeTx( + blockchain.EventTransfer, + val.PubKeyHex(), + newVal.PubKeyHex(), + 2*blockchain.MinValidatorStake, blockchain.MinFee, + mustJSON(blockchain.TransferPayload{}), + ) + // Candidate stakes the minimum. + stakeTx := makeTx( + blockchain.EventStake, + newVal.PubKeyHex(), + newVal.PubKeyHex(), + blockchain.MinValidatorStake, blockchain.MinFee, + nil, + ) + preBlock := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx, stakeTx}) + mustAddBlock(t, c, preBlock) + + tx := makeTx( + blockchain.EventAddValidator, + val.PubKeyHex(), + newVal.PubKeyHex(), + 0, blockchain.MinFee, + mustJSON(blockchain.AddValidatorPayload{Reason: "test"}), + ) + b1 := buildBlock(t, preBlock, val, []*blockchain.Transaction{tx}) + mustAddBlock(t, c, b1) + + set, err := c.ValidatorSet() + if err != nil { + t.Fatalf("ValidatorSet: %v", err) + } + found := false + for _, k := range set { + if k == newVal.PubKeyHex() { + found = true + break + } + } + if !found { + t.Errorf("new validator %s not found in set after ADD_VALIDATOR tx", newVal.PubKeyHex()[:8]) + } +} + +// 9. EventRemoveValidator removes a key from the set. +// +// Updated for P2.2 (multi-sig forced removal): the sender and the +// cosigners must together reach ⌈2/3⌉ of the current set. Here we have +// 3 validators, so 2 approvals are needed. `val` sends, `coSigner` adds +// a signature for RemoveDigest(removeMe.Pub). +func TestRemoveValidatorTx(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + coSigner := newIdentity(t) + removeMe := newIdentity(t) + + // All three start as validators (ceil(2/3 * 3) = 2 approvals needed). + if err := c.InitValidators([]string{val.PubKeyHex(), coSigner.PubKeyHex(), removeMe.PubKeyHex()}); err != nil { + t.Fatalf("InitValidators: %v", err) + } + + genesis := addGenesis(t, c, val) + + // coSigner produces an off-chain approval for removing removeMe. + sig := coSigner.Sign(blockchain.RemoveDigest(removeMe.PubKeyHex())) + + tx := makeTx( + blockchain.EventRemoveValidator, + val.PubKeyHex(), + removeMe.PubKeyHex(), + 0, blockchain.MinFee, + mustJSON(blockchain.RemoveValidatorPayload{ + Reason: "test", + CoSignatures: []blockchain.ValidatorCoSig{ + {PubKey: coSigner.PubKeyHex(), Signature: sig}, + }, + }), + ) + b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{tx}) + mustAddBlock(t, c, b1) + + set, err := c.ValidatorSet() + if err != nil { + t.Fatalf("ValidatorSet: %v", err) + } + for _, k := range set { + if k == removeMe.PubKeyHex() { + t.Errorf("removed validator %s still in set", removeMe.PubKeyHex()[:8]) + } + } +} + +// 10. ADD_VALIDATOR tx from a non-validator must fail. +func TestAddValidatorNotAValidator(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + nonVal := newIdentity(t) + target := newIdentity(t) + + if err := c.InitValidators([]string{val.PubKeyHex()}); err != nil { + t.Fatalf("InitValidators: %v", err) + } + genesis := addGenesis(t, c, val) + + // Fund nonVal so the debit doesn't fail first (it should fail on validator check). + fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), nonVal.PubKeyHex(), + 10*blockchain.Token, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) + b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx}) + mustAddBlock(t, c, b1) + + badTx := makeTx( + blockchain.EventAddValidator, + nonVal.PubKeyHex(), // not a validator + target.PubKeyHex(), + 0, blockchain.MinFee, + mustJSON(blockchain.AddValidatorPayload{}), + ) + b2 := buildBlock(t, b1, val, []*blockchain.Transaction{badTx}) + // AddBlock must succeed — the bad tx is skipped rather than rejecting the block. + if err := c.AddBlock(b2); err != nil { + t.Fatalf("AddBlock returned unexpected error: %v", err) + } + // target must NOT have been added as a validator (tx was skipped). + vset, err := c.ValidatorSet() + if err != nil { + t.Fatalf("ValidatorSet: %v", err) + } + for _, v := range vset { + if v == target.PubKeyHex() { + t.Error("target was added as validator despite tx being from a non-validator (should have been skipped)") + } + } +} + +// 11. RelayProof with valid FeeSig transfers the relay fee from sender to relay. +func TestRelayProofClaimsFee(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + sender := newIdentity(t) + relay := newIdentity(t) + + genesis := addGenesis(t, c, val) + + const relayFeeUT = 5_000 * blockchain.MicroToken + + // Fund sender with enough to cover relay fee and tx fee. + fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), sender.PubKeyHex(), + relayFeeUT+blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) + b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx}) + mustAddBlock(t, c, b1) + + senderBalBefore := mustBalance(t, c, sender.PubKeyHex()) + relayBalBefore := mustBalance(t, c, relay.PubKeyHex()) + + envelopeID := "env-abc123" + authBytes := blockchain.FeeAuthBytes(envelopeID, relayFeeUT) + feeSig := sender.Sign(authBytes) + + envelopeHash := sha256.Sum256([]byte("fake-ciphertext")) + proofPayload := blockchain.RelayProofPayload{ + EnvelopeID: envelopeID, + EnvelopeHash: envelopeHash[:], + SenderPubKey: sender.PubKeyHex(), + FeeUT: relayFeeUT, + FeeSig: feeSig, + RelayPubKey: relay.PubKeyHex(), + DeliveredAt: time.Now().Unix(), + } + tx := makeTx( + blockchain.EventRelayProof, + relay.PubKeyHex(), + "", + 0, blockchain.MinFee, + mustJSON(proofPayload), + ) + + b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx}) + mustAddBlock(t, c, b2) + + senderBalAfter := mustBalance(t, c, sender.PubKeyHex()) + relayBalAfter := mustBalance(t, c, relay.PubKeyHex()) + + if senderBalAfter != senderBalBefore-relayFeeUT { + t.Errorf("sender balance: got %d, want %d (before %d - fee %d)", + senderBalAfter, senderBalBefore-relayFeeUT, senderBalBefore, relayFeeUT) + } + if relayBalAfter != relayBalBefore+relayFeeUT { + t.Errorf("relay balance: got %d, want %d (before %d + fee %d)", + relayBalAfter, relayBalBefore+relayFeeUT, relayBalBefore, relayFeeUT) + } +} + +// 12. RelayProof with wrong FeeSig must fail AddBlock. +func TestRelayProofBadSig(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + sender := newIdentity(t) + relay := newIdentity(t) + imposter := newIdentity(t) // signs instead of sender + + genesis := addGenesis(t, c, val) + + const relayFeeUT = 5_000 * blockchain.MicroToken + + // Fund sender. + fundTx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), sender.PubKeyHex(), + relayFeeUT+blockchain.MinFee, blockchain.MinFee, mustJSON(blockchain.TransferPayload{})) + b1 := buildBlock(t, genesis, val, []*blockchain.Transaction{fundTx}) + mustAddBlock(t, c, b1) + + senderBalBefore := mustBalance(t, c, sender.PubKeyHex()) + + envelopeID := "env-xyz" + authBytes := blockchain.FeeAuthBytes(envelopeID, relayFeeUT) + // Imposter signs, not the actual sender. + badFeeSig := imposter.Sign(authBytes) + + envelopeHash := sha256.Sum256([]byte("ciphertext")) + proofPayload := blockchain.RelayProofPayload{ + EnvelopeID: envelopeID, + EnvelopeHash: envelopeHash[:], + SenderPubKey: sender.PubKeyHex(), // claims sender, but sig is from imposter + FeeUT: relayFeeUT, + FeeSig: badFeeSig, + RelayPubKey: relay.PubKeyHex(), + DeliveredAt: time.Now().Unix(), + } + tx := makeTx( + blockchain.EventRelayProof, + relay.PubKeyHex(), + "", + 0, blockchain.MinFee, + mustJSON(proofPayload), + ) + b2 := buildBlock(t, b1, val, []*blockchain.Transaction{tx}) + // AddBlock must succeed — the bad tx is skipped rather than rejecting the block. + if err := c.AddBlock(b2); err != nil { + t.Fatalf("AddBlock returned unexpected error: %v", err) + } + // Sender's balance must be unchanged — the skipped tx had no effect. + senderBalAfter, err := c.Balance(sender.PubKeyHex()) + if err != nil { + t.Fatalf("Balance: %v", err) + } + if senderBalAfter != senderBalBefore { + t.Errorf("sender balance changed despite bad-sig tx: before=%d after=%d", + senderBalBefore, senderBalAfter) + } +} + +// 13. Adding the same block index twice must fail. +func TestDuplicateBlockRejected(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + + genesis := addGenesis(t, c, val) + + // Build block 1. + b1 := buildBlock(t, genesis, val, nil) + mustAddBlock(t, c, b1) + + // Build an independent block also claiming index 1 (different hash). + b1dup := &blockchain.Block{ + Index: 1, + Timestamp: time.Now().Add(time.Millisecond).UTC(), + Transactions: []*blockchain.Transaction{}, + PrevHash: genesis.Hash, + Validator: val.PubKeyHex(), + TotalFees: 0, + } + b1dup.ComputeHash() + b1dup.Sign(val.PrivKey) + + // The chain tip is already at index 1; the new block has index 1 but a + // different prevHash (its own prev is genesis too but tip.Hash ≠ genesis.Hash). + if err := c.AddBlock(b1dup); err == nil { + t.Fatal("expected AddBlock to fail for duplicate index, but it succeeded") + } +} + +// 14. Block with wrong prevHash must fail. +func TestChainLinkageRejected(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + + genesis := addGenesis(t, c, val) + + // Create a block with a garbage prevHash. + garbagePrev := make([]byte, 32) + if _, err := rand.Read(garbagePrev); err != nil { + t.Fatalf("rand.Read: %v", err) + } + badBlock := &blockchain.Block{ + Index: 1, + Timestamp: time.Now().UTC(), + Transactions: []*blockchain.Transaction{}, + PrevHash: garbagePrev, + Validator: val.PubKeyHex(), + TotalFees: 0, + } + badBlock.ComputeHash() + badBlock.Sign(val.PrivKey) + + if err := c.AddBlock(badBlock); err == nil { + t.Fatal("expected AddBlock to fail for wrong prevHash, but it succeeded") + } + + // Tip must still be genesis. + tip := c.Tip() + if tip.Index != genesis.Index { + t.Errorf("tip index after rejection: got %d, want %d", tip.Index, genesis.Index) + } +} + +// 15. Tip advances with each successfully committed block. +func TestTipUpdates(t *testing.T) { + c := newChain(t) + val := newIdentity(t) + + if tip := c.Tip(); tip != nil { + t.Fatalf("tip on empty chain: expected nil, got index %d", tip.Index) + } + + genesis := addGenesis(t, c, val) + if tip := c.Tip(); tip == nil || tip.Index != 0 { + t.Fatalf("tip after genesis: expected index 0, got %v", tip) + } + + prev := genesis + for i := uint64(1); i <= 3; i++ { + b := buildBlock(t, prev, val, nil) + mustAddBlock(t, c, b) + + tip := c.Tip() + if tip == nil { + t.Fatalf("tip is nil after block %d", i) + } + if tip.Index != i { + t.Errorf("tip.Index after block %d: got %d, want %d", i, tip.Index, i) + } + prev = b + } +} + +// ─── compile-time guard ────────────────────────────────────────────────────── + +// Ensure the identity package is used directly so the import is not trimmed. +var _ = identity.Generate + +// Ensure ed25519 and hex are used directly (they may be used via helpers). +var _ = ed25519.PublicKey(nil) +var _ = hex.EncodeToString diff --git a/blockchain/equivocation.go b/blockchain/equivocation.go new file mode 100644 index 0000000..bebc972 --- /dev/null +++ b/blockchain/equivocation.go @@ -0,0 +1,101 @@ +// Package blockchain — equivocation evidence verification for SLASH txs. +// +// "Equivocation" = a validator signing two different consensus messages +// at the same height+view+phase, each endorsing a different block hash. +// PBFT safety depends on validators NOT doing this; a malicious validator +// that equivocates can split honest nodes into disagreeing majorities. +// +// The SLASH tx embeds an EquivocationEvidence payload carrying both +// conflicting messages. Any node (not just the victim) can submit it; +// on-chain verification is purely cryptographic — no "trust me" from the +// submitter. If the evidence is valid, the offender's stake is burned and +// they're removed from the validator set. +package blockchain + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" +) + +// EquivocationEvidence is embedded (as JSON bytes) in SlashPayload.Evidence +// when Reason == "equivocation". Two distinct consensus messages from the +// same validator at the same consensus position prove they are trying to +// fork the chain. +type EquivocationEvidence struct { + A *ConsensusMsg `json:"a"` + B *ConsensusMsg `json:"b"` +} + +// ValidateEquivocation verifies that the two messages constitute genuine +// equivocation evidence against `offender`. Returns nil on success; +// errors are returned with enough detail for the applyTx caller to log +// why a slash was rejected. +// +// Rules: +// - Both messages must be signed by `offender` (From = offender, +// signature verifies against the offender's Ed25519 pubkey). +// - Same Type (MsgPrepare or MsgCommit — we don't slash for equivocating +// on PrePrepare since leaders can legitimately re-propose). +// - Same View, same SeqNum — equivocation is about the same consensus +// round. +// - Distinct BlockHash — otherwise the two messages are identical and +// not actually contradictory. +// - Both sigs verify against the offender's pubkey. +func ValidateEquivocation(offender string, ev *EquivocationEvidence) error { + if ev == nil || ev.A == nil || ev.B == nil { + return fmt.Errorf("equivocation: missing message(s)") + } + if ev.A.From != offender || ev.B.From != offender { + return fmt.Errorf("equivocation: messages not from offender %s", offender[:8]) + } + // Only PREPARE / COMMIT equivocation is slashable. PRE-PREPARE double- + // proposals are expected during view changes — the protocol tolerates + // them. + if ev.A.Type != ev.B.Type { + return fmt.Errorf("equivocation: messages are different types (%v vs %v)", ev.A.Type, ev.B.Type) + } + if ev.A.Type != MsgPrepare && ev.A.Type != MsgCommit { + return fmt.Errorf("equivocation: only PREPARE/COMMIT are slashable (got %v)", ev.A.Type) + } + if ev.A.View != ev.B.View { + return fmt.Errorf("equivocation: different views (%d vs %d)", ev.A.View, ev.B.View) + } + if ev.A.SeqNum != ev.B.SeqNum { + return fmt.Errorf("equivocation: different seqnums (%d vs %d)", ev.A.SeqNum, ev.B.SeqNum) + } + if bytes.Equal(ev.A.BlockHash, ev.B.BlockHash) { + return fmt.Errorf("equivocation: messages endorse the same block") + } + + // Decode pubkey + verify both signatures over the canonical bytes. + pubBytes, err := hex.DecodeString(offender) + if err != nil || len(pubBytes) != ed25519.PublicKeySize { + return fmt.Errorf("equivocation: bad offender pubkey") + } + pub := ed25519.PublicKey(pubBytes) + + if !ed25519.Verify(pub, consensusMsgSignBytes(ev.A), ev.A.Signature) { + return fmt.Errorf("equivocation: signature A does not verify") + } + if !ed25519.Verify(pub, consensusMsgSignBytes(ev.B), ev.B.Signature) { + return fmt.Errorf("equivocation: signature B does not verify") + } + return nil +} + +// consensusMsgSignBytes MUST match consensus/pbft.go:msgSignBytes exactly. +// We duplicate it here (instead of importing consensus) to keep the +// blockchain package free of a consensus dependency — consensus already +// imports blockchain for types. +func consensusMsgSignBytes(msg *ConsensusMsg) []byte { + tmp := *msg + tmp.Signature = nil + tmp.Block = nil + data, _ := json.Marshal(tmp) + h := sha256.Sum256(data) + return h[:] +} diff --git a/blockchain/index.go b/blockchain/index.go new file mode 100644 index 0000000..737e2fa --- /dev/null +++ b/blockchain/index.go @@ -0,0 +1,562 @@ +package blockchain + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + badger "github.com/dgraph-io/badger/v4" +) + +// Index key prefixes +const ( + prefixTxRecord = "tx:" // tx: → TxRecord JSON + prefixTxByAddr = "txaddr:" // txaddr::: → "" (empty value) + prefixAddrMap = "addrmap:" // addrmap: → pubkey hex + prefixNetStats = "netstats" // netstats → NetStats JSON + syntheticRewardIDPrefix = "sys-reward-" +) + +// TxRecord wraps a Transaction with its on-chain context. +type TxRecord struct { + Tx *Transaction `json:"tx"` + BlockIndex uint64 `json:"block_index"` + BlockHash string `json:"block_hash"` + BlockTime time.Time `json:"block_time"` + GasUsed uint64 `json:"gas_used,omitempty"` +} + +// NetStats are aggregate counters updated every block. +type NetStats struct { + TotalBlocks uint64 `json:"total_blocks"` + TotalTxs uint64 `json:"total_txs"` + TotalTransfers uint64 `json:"total_transfers"` + TotalRelayProofs uint64 `json:"total_relay_proofs"` + TotalSupply uint64 `json:"total_supply"` // µT ever minted via rewards + grants + ValidatorCount int `json:"validator_count"` + RelayCount int `json:"relay_count"` +} + +// indexBlock is called inside AddBlock's db.Update() — indexes all transactions +// in the block and updates aggregate stats. +// gasUsed maps tx.ID → gas consumed for CALL_CONTRACT transactions. +func (c *Chain) indexBlock(txn *badger.Txn, b *Block, gasUsed map[string]uint64) error { + // Load existing stats + stats, err := c.readNetStats(txn) + if err != nil { + return err + } + stats.TotalBlocks = b.Index + 1 + // TotalSupply is fixed at GenesisAllocation; update it once at genesis. + if b.Index == 0 { + stats.TotalSupply = GenesisAllocation + } + + for seq, tx := range b.Transactions { + // Store full TxRecord — but never overwrite an existing record. + // The same TX can appear in multiple gossiped blocks due to a mempool/PBFT + // race; the first block that actually applies it (via applyTx) will have + // gasUsed > 0. Subsequent re-indexings with an empty gasUsedByTx map + // would zero out the stored GasUsed. Skip if the record already exists. + recKey := []byte(prefixTxRecord + tx.ID) + if _, existErr := txn.Get(recKey); existErr == nil { + // TxRecord already written (from an earlier block or earlier call); + // do not overwrite it. + continue + } + // Chronological index entry (txchron:: → tx_id). + // Lets RecentTxs iterate tx-by-tx instead of block-by-block so chains + // with many empty blocks still answer /api/txs/recent in O(limit). + chronKey := fmt.Sprintf("%s%020d:%04d", prefixTxChron, b.Index, seq) + if err := txn.Set([]byte(chronKey), []byte(tx.ID)); err != nil { + return err + } + gasForTx := gasUsed[tx.ID] + rec := TxRecord{ + Tx: tx, + BlockIndex: b.Index, + BlockHash: b.HashHex(), + BlockTime: b.Timestamp, + GasUsed: gasForTx, + } + val, err := json.Marshal(rec) + if err != nil { + return err + } + if err := txn.Set(recKey, val); err != nil { + return err + } + + // Index by sender + if tx.From != "" { + addrKey := txAddrKey(tx.From, b.Index, tx.ID) + if err := txn.Set([]byte(addrKey), []byte{}); err != nil { + return err + } + // Store addr → pubkey mapping + if err := c.storeAddrMap(txn, tx.From); err != nil { + return err + } + } + // Index by recipient + if tx.To != "" && tx.To != tx.From { + addrKey := txAddrKey(tx.To, b.Index, tx.ID) + if err := txn.Set([]byte(addrKey), []byte{}); err != nil { + return err + } + if err := c.storeAddrMap(txn, tx.To); err != nil { + return err + } + } + + // Update aggregate counters + stats.TotalTxs++ + switch tx.Type { + case EventTransfer: + stats.TotalTransfers++ + case EventRelayProof: + stats.TotalRelayProofs++ + } + } + + // Index synthetic block reward only when the validator actually earned fees, + // or for the genesis block (one-time allocation). Empty blocks produce no + // state change and no income, so there is nothing useful to show. + if b.TotalFees > 0 || b.Index == 0 { + rewardTarget, err := c.resolveRewardTarget(txn, b.Validator) + if err != nil { + return err + } + rewardTx, err := makeBlockRewardTx(b, rewardTarget) + if err != nil { + return err + } + rewardRec := TxRecord{ + Tx: rewardTx, + BlockIndex: b.Index, + BlockHash: b.HashHex(), + BlockTime: b.Timestamp, + } + rewardVal, err := json.Marshal(rewardRec) + if err != nil { + return err + } + if err := txn.Set([]byte(prefixTxRecord+rewardTx.ID), rewardVal); err != nil { + return err + } + if rewardTx.From != "" { + if err := txn.Set([]byte(txAddrKey(rewardTx.From, b.Index, rewardTx.ID)), []byte{}); err != nil { + return err + } + if err := c.storeAddrMap(txn, rewardTx.From); err != nil { + return err + } + } + if rewardTx.To != "" && rewardTx.To != rewardTx.From { + if err := txn.Set([]byte(txAddrKey(rewardTx.To, b.Index, rewardTx.ID)), []byte{}); err != nil { + return err + } + if err := c.storeAddrMap(txn, rewardTx.To); err != nil { + return err + } + } + } + + // Persist updated stats + return c.writeNetStats(txn, stats) +} + +func makeBlockRewardTx(b *Block, rewardTarget string) (*Transaction, error) { + var memo string + if b.Index == 0 { + memo = fmt.Sprintf("Genesis allocation: %d µT", GenesisAllocation) + } else { + memo = fmt.Sprintf("Block fees: %d µT", b.TotalFees) + } + + total := b.TotalFees + if b.Index == 0 { + total = GenesisAllocation + } + + payload, err := json.Marshal(BlockRewardPayload{ + ValidatorPubKey: b.Validator, + TargetPubKey: rewardTarget, + FeeReward: b.TotalFees, + TotalReward: total, + }) + if err != nil { + return nil, err + } + // From is intentionally left empty: a block reward is a synthetic, freshly + // minted allocation (fees collected by the network) rather than a transfer + // from an actual account. Leaving From="" prevents the reward from appearing + // as "validator paid themselves" in the explorer/client when the validator + // has no separate wallet binding (rewardTarget == b.Validator). + // b.Validator is still recorded inside the payload (BlockRewardPayload). + return &Transaction{ + ID: fmt.Sprintf("%s%020d", syntheticRewardIDPrefix, b.Index), + Type: EventBlockReward, + From: "", + To: rewardTarget, + Amount: total, + Fee: 0, + Memo: memo, + Payload: payload, + Timestamp: b.Timestamp, + }, nil +} + +// txAddrKey builds the composite key: txaddr::: +func txAddrKey(pubKey string, blockIdx uint64, txID string) string { + return fmt.Sprintf("%s%s:%020d:%s", prefixTxByAddr, pubKey, blockIdx, txID) +} + +// storeAddrMap stores a DC address → pubkey mapping. +func (c *Chain) storeAddrMap(txn *badger.Txn, pubKey string) error { + addr := pubKeyToAddr(pubKey) + return txn.Set([]byte(prefixAddrMap+addr), []byte(pubKey)) +} + +// pubKeyToAddr converts a hex Ed25519 public key to a DC address. +// Replicates wallet.PubKeyToAddress without importing the wallet package. +func pubKeyToAddr(pubKeyHex string) string { + raw, err := hex.DecodeString(pubKeyHex) + if err != nil { + return pubKeyHex // fallback: use pubkey as-is + } + h := sha256.Sum256(raw) + return "DC" + hex.EncodeToString(h[:12]) +} + +// --- Public query methods --- + +// TxByID returns a TxRecord by transaction ID. +func (c *Chain) TxByID(txID string) (*TxRecord, error) { + var rec TxRecord + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixTxRecord + txID)) + if err != nil { + return err + } + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &rec) + }) + }) + if errors.Is(err, badger.ErrKeyNotFound) { + synth, synthErr := c.syntheticTxByID(txID) + if synthErr != nil { + return nil, synthErr + } + if synth != nil { + return synth, nil + } + return nil, nil + } + return &rec, err +} + +func parseSyntheticRewardIndex(txID string) (uint64, bool) { + if !strings.HasPrefix(txID, syntheticRewardIDPrefix) { + return 0, false + } + part := strings.TrimPrefix(txID, syntheticRewardIDPrefix) + idx, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return 0, false + } + return idx, true +} + +func (c *Chain) syntheticTxByID(txID string) (*TxRecord, error) { + idx, ok := parseSyntheticRewardIndex(txID) + if !ok { + return nil, nil + } + b, err := c.GetBlock(idx) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + + rewardTarget := b.Validator + binding, err := c.WalletBinding(b.Validator) + if err == nil && binding != "" { + rewardTarget = binding + } + rewardTx, err := makeBlockRewardTx(b, rewardTarget) + if err != nil { + return nil, err + } + return &TxRecord{ + Tx: rewardTx, + BlockIndex: b.Index, + BlockHash: b.HashHex(), + BlockTime: b.Timestamp, + }, nil +} + +// TxsByAddress returns up to limit TxRecords for a public key, newest first, +// skipping the first offset results (for pagination). +func (c *Chain) TxsByAddress(pubKey string, limit, offset int) ([]*TxRecord, error) { + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + prefix := prefixTxByAddr + pubKey + ":" + + // First: collect TxID keys for this address (newest first via reverse iter), + // skipping `offset` entries. + var txIDs []string + err := c.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.Reverse = true + opts.PrefetchValues = false + it := txn.NewIterator(opts) + defer it.Close() + + seekKey := prefix + "\xff\xff\xff\xff\xff\xff\xff\xff" + skipped := 0 + for it.Seek([]byte(seekKey)); it.Valid(); it.Next() { + key := string(it.Item().Key()) + if !strings.HasPrefix(key, prefix) { + break + } + parts := strings.SplitN(key[len(prefix):], ":", 2) + if len(parts) != 2 { + continue + } + if skipped < offset { + skipped++ + continue + } + txIDs = append(txIDs, parts[1]) + if len(txIDs) >= limit { + break + } + } + return nil + }) + if err != nil { + return nil, err + } + + // Now fetch each TxRecord + var records []*TxRecord + err = c.db.View(func(txn *badger.Txn) error { + for _, txID := range txIDs { + item, err := txn.Get([]byte(prefixTxRecord + txID)) + if errors.Is(err, badger.ErrKeyNotFound) { + continue + } + if err != nil { + return err + } + var rec TxRecord + if err := item.Value(func(val []byte) error { + return json.Unmarshal(val, &rec) + }); err != nil { + return err + } + records = append(records, &rec) + } + return nil + }) + return records, err +} + +// RecentTxs returns the N most recent transactions across all blocks. +func (c *Chain) RecentTxs(limit int) ([]*TxRecord, error) { + if limit <= 0 { + limit = 20 + } + // Primary path: iterate the chronological tx index in reverse. This is + // O(limit) regardless of how many empty blocks sit between txs. + var records []*TxRecord + err := c.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.Reverse = true + opts.PrefetchValues = true + it := txn.NewIterator(opts) + defer it.Close() + + // Seek to the highest possible key under this prefix. + seekKey := []byte(prefixTxChron + "\xff") + for it.Seek(seekKey); it.ValidForPrefix([]byte(prefixTxChron)); it.Next() { + if len(records) >= limit { + break + } + var txID string + err := it.Item().Value(func(v []byte) error { + txID = string(v) + return nil + }) + if err != nil || txID == "" { + continue + } + recItem, err := txn.Get([]byte(prefixTxRecord + txID)) + if err != nil { + continue + } + var rec TxRecord + if err := recItem.Value(func(v []byte) error { return json.Unmarshal(v, &rec) }); err != nil { + continue + } + records = append(records, &rec) + } + return nil + }) + if err == nil && len(records) >= limit { + return records, nil + } + + // Fallback (legacy + reward-tx injection): reverse-scan blocks. + // Only blocks committed BEFORE the chronological index existed will be + // found this way; we cap the scan so it can't hang. + tipIdx := c.TipIndex() + const maxBlockScan = 5000 + + seen := make(map[string]bool, len(records)) + for _, r := range records { + seen[r.Tx.ID] = true + } + + scanned := 0 + for idx := int64(tipIdx); idx >= 0 && len(records) < limit && scanned < maxBlockScan; idx-- { + scanned++ + b, err := c.GetBlock(uint64(idx)) + if err != nil { + break + } + for i := len(b.Transactions) - 1; i >= 0 && len(records) < limit; i-- { + tx := b.Transactions[i] + if seen[tx.ID] { + continue + } + records = append(records, &TxRecord{ + Tx: tx, + BlockIndex: b.Index, + BlockHash: b.HashHex(), + BlockTime: b.Timestamp, + }) + } + // Include BLOCK_REWARD only for fee-earning blocks and genesis. + if len(records) < limit && (b.TotalFees > 0 || b.Index == 0) { + rewardTarget := b.Validator + if binding, err2 := c.WalletBinding(b.Validator); err2 == nil && binding != "" { + rewardTarget = binding + } + if rewardTx, err2 := makeBlockRewardTx(b, rewardTarget); err2 == nil { + records = append(records, &TxRecord{ + Tx: rewardTx, + BlockIndex: b.Index, + BlockHash: b.HashHex(), + BlockTime: b.Timestamp, + }) + } + } + } + return records, nil +} + +// RecentBlocks returns the N most recent blocks (tip first). +func (c *Chain) RecentBlocks(limit int) ([]*Block, error) { + if limit <= 0 { + limit = 10 + } + // Lock-free tip lookup so this endpoint never blocks on consensus work. + tipIdx := c.TipIndex() + var blocks []*Block + for idx := int64(tipIdx); idx >= 0 && len(blocks) < limit; idx-- { + b, err := c.GetBlock(uint64(idx)) + if err != nil { + break + } + blocks = append(blocks, b) + } + return blocks, nil +} + +// NetworkStats returns aggregate counters for the chain. +// ValidatorCount and RelayCount are always live-counted from the DB so they +// are accurate even after InitValidators replaced the set or relays registered. +func (c *Chain) NetworkStats() (NetStats, error) { + var stats NetStats + err := c.db.View(func(txn *badger.Txn) error { + s, err := c.readNetStats(txn) + if err != nil { + return err + } + stats = s + + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + it := txn.NewIterator(opts) + defer it.Close() + + vPrefix := []byte(prefixValidator) + for it.Seek(vPrefix); it.ValidForPrefix(vPrefix); it.Next() { + stats.ValidatorCount++ + } + rPrefix := []byte(prefixRelay) + for it.Seek(rPrefix); it.ValidForPrefix(rPrefix); it.Next() { + stats.RelayCount++ + } + return nil + }) + return stats, err +} + +// AddressToPubKey resolves a DC address to a pub key. +// Returns "" if not found. +func (c *Chain) AddressToPubKey(addr string) (string, error) { + var pubKey string + err := c.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(prefixAddrMap + addr)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { + pubKey = string(val) + return nil + }) + }) + return pubKey, err +} + +// --- internal --- + +func (c *Chain) readNetStats(txn *badger.Txn) (NetStats, error) { + var s NetStats + item, err := txn.Get([]byte(prefixNetStats)) + if errors.Is(err, badger.ErrKeyNotFound) { + return s, nil + } + if err != nil { + return s, err + } + err = item.Value(func(val []byte) error { + return json.Unmarshal(val, &s) + }) + return s, err +} + +func (c *Chain) writeNetStats(txn *badger.Txn, s NetStats) error { + val, err := json.Marshal(s) + if err != nil { + return err + } + return txn.Set([]byte(prefixNetStats), val) +} diff --git a/blockchain/native.go b/blockchain/native.go new file mode 100644 index 0000000..9a86ad2 --- /dev/null +++ b/blockchain/native.go @@ -0,0 +1,238 @@ +// Package blockchain — native (non-WASM) contract infrastructure. +// +// System contracts like `username_registry` are latency-sensitive and must +// never hang the chain. Running them through the WASM VM means: +// +// - 100× the CPU cost of equivalent Go code; +// - a bug in gas metering / opcode instrumentation can freeze AddBlock +// indefinitely (see the hangs that motivated this rewrite); +// - every node needs an identical wazero build — extra supply-chain risk. +// +// Native contracts are written as plain Go code against a narrow interface +// (NativeContract below). They share the same contract_id space, ABI, and +// explorer views as WASM contracts, so clients can't tell them apart — the +// dispatcher in applyTx just routes the call to Go instead of wazero when +// it sees a native contract_id. +// +// Authorship notes: +// - A native contract has full, direct access to the current BadgerDB txn +// and chain helpers via NativeContext. It MUST only read/write keys +// prefixed with `cstate::` or `clog::…` — same +// as WASM contracts see. This keeps on-chain state cleanly segregated +// so one day we can migrate a native contract back to WASM (or vice +// versa) without a storage migration. +// - A native contract MUST return deterministic errors. The dispatcher +// treats any returned error as `ErrTxFailed`-wrapped — fees stay +// debited, but state changes roll back with the enclosing Badger txn. +package blockchain + +import ( + "encoding/json" + "errors" + "fmt" + + badger "github.com/dgraph-io/badger/v4" +) + +// NativeContract is the Go-side counterpart of a WASM smart contract. +// +// Implementors are expected to be stateless (all state lives in BadgerDB +// under cstate::…). An instance is created once per chain and +// reused across all calls. +type NativeContract interface { + // ID returns the deterministic contract ID used in CALL_CONTRACT txs. + // Must be stable across node restarts and identical on every node. + ID() string + + // ABI returns a JSON document describing the contract's methods. + // Identical shape to the WASM contracts' *_abi.json files so the + // well-known endpoint and explorer can discover it uniformly. + ABI() string + + // Call dispatches a method invocation. Returns the gas it wants to + // charge (will be multiplied by the current gas price). Returning an + // error aborts the tx; returning (0, nil) means free success. + Call(ctx *NativeContext, method string, argsJSON []byte) (gasUsed uint64, err error) +} + +// NativeContext hands a native contract the minimum it needs to run, +// without exposing the full Chain type (which would tempt contracts to +// touch state they shouldn't). +type NativeContext struct { + Txn *badger.Txn + ContractID string + Caller string // hex Ed25519 pubkey of the tx sender + TxID string + BlockHeight uint64 + + // TxAmount is tx.Amount — the payment the caller attached to this call + // in µT. It is NOT auto-debited from the caller; the contract decides + // whether to collect it (via ctx.Debit), refund, or ignore. Exposing + // payment via tx.Amount (instead of an implicit debit inside the + // contract) makes contract costs visible in the explorer — a user can + // see exactly what a call charges by reading the tx envelope. + TxAmount uint64 + + // chain is kept unexported; contract code uses the helper methods below + // rather than reaching into Chain directly. + chain *Chain +} + +// Balance returns the balance of the given pubkey in µT. +func (ctx *NativeContext) Balance(pubHex string) uint64 { + var bal uint64 + item, err := ctx.Txn.Get([]byte(prefixBalance + pubHex)) + if errors.Is(err, badger.ErrKeyNotFound) { + return 0 + } + if err != nil { + return 0 + } + _ = item.Value(func(val []byte) error { + return unmarshalUint64(val, &bal) + }) + return bal +} + +// Debit removes amt µT from pub's balance, or returns an error if insufficient. +func (ctx *NativeContext) Debit(pub string, amt uint64) error { + return ctx.chain.debitBalance(ctx.Txn, pub, amt) +} + +// Credit adds amt µT to pub's balance. +func (ctx *NativeContext) Credit(pub string, amt uint64) error { + return ctx.chain.creditBalance(ctx.Txn, pub, amt) +} + +// Get reads a contract-scoped state value. Returns nil if not set. +func (ctx *NativeContext) Get(key string) ([]byte, error) { + item, err := ctx.Txn.Get([]byte(prefixContractState + ctx.ContractID + ":" + key)) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + var out []byte + err = item.Value(func(v []byte) error { + out = append([]byte(nil), v...) + return nil + }) + return out, err +} + +// Set writes a contract-scoped state value. +func (ctx *NativeContext) Set(key string, value []byte) error { + return ctx.Txn.Set([]byte(prefixContractState+ctx.ContractID+":"+key), value) +} + +// Delete removes a contract-scoped state value. +func (ctx *NativeContext) Delete(key string) error { + return ctx.Txn.Delete([]byte(prefixContractState + ctx.ContractID + ":" + key)) +} + +// Log emits a contract log line for the explorer. Uses the same storage as +// WASM contracts' env.log() so the explorer renders them identically. +func (ctx *NativeContext) Log(msg string) error { + return ctx.chain.writeContractLog(ctx.Txn, ctx.ContractID, ctx.BlockHeight, ctx.TxID, msg) +} + +// ─── Native contract registry ──────────────────────────────────────────────── + +// Native contracts are registered into the chain once during chain setup +// (typically right after `NewChain`). Lookups happen on every CALL_CONTRACT +// and DEPLOY_CONTRACT — they're hot path, so the registry is a plain map +// guarded by a RW mutex. + +// RegisterNative associates a NativeContract with its ID on this chain. +// Panics if two contracts share an ID (clear programmer error). +// Must be called before AddBlock begins processing user transactions. +// +// Uses a DEDICATED mutex (c.nativeMu) rather than c.mu, because +// lookupNative is called from inside applyTx which runs under c.mu.Lock(). +// sync.RWMutex is non-reentrant — reusing c.mu would deadlock. +func (c *Chain) RegisterNative(nc NativeContract) { + c.nativeMu.Lock() + defer c.nativeMu.Unlock() + if c.native == nil { + c.native = make(map[string]NativeContract) + } + if _, exists := c.native[nc.ID()]; exists { + panic(fmt.Sprintf("native contract %s registered twice", nc.ID())) + } + c.native[nc.ID()] = nc +} + +// lookupNative returns the registered native contract for id, or nil. +// Hot path — called on every CALL_CONTRACT from applyTx. Safe to call while +// c.mu is held because we use a separate RWMutex here. +func (c *Chain) lookupNative(id string) NativeContract { + c.nativeMu.RLock() + defer c.nativeMu.RUnlock() + return c.native[id] +} + +// NativeContracts returns a snapshot of every native contract registered on +// this chain. Used by the well-known endpoint so clients auto-discover +// system services without the user having to paste contract IDs. +func (c *Chain) NativeContracts() []NativeContract { + c.nativeMu.RLock() + defer c.nativeMu.RUnlock() + out := make([]NativeContract, 0, len(c.native)) + for _, nc := range c.native { + out = append(out, nc) + } + return out +} + +// writeContractLog is the shared log emitter for both WASM and native +// contracts. Keeping it here (on Chain) means we can change the log key +// layout in one place. +func (c *Chain) writeContractLog(txn *badger.Txn, contractID string, blockHeight uint64, txID, msg string) error { + // Best-effort: match the existing WASM log format so the explorer's + // renderer doesn't need to branch. + seq := c.nextContractLogSeq(txn, contractID, blockHeight) + entry := ContractLogEntry{ + ContractID: contractID, + BlockHeight: blockHeight, + TxID: txID, + Seq: int(seq), + Message: msg, + } + val, err := json.Marshal(entry) + if err != nil { + return err + } + key := fmt.Sprintf("%s%s:%020d:%05d", prefixContractLog, contractID, blockHeight, seq) + return txn.Set([]byte(key), val) +} + +// nextContractLogSeq returns the next sequence number for a (contract,block) +// pair by counting existing entries under the prefix. +func (c *Chain) nextContractLogSeq(txn *badger.Txn, contractID string, blockHeight uint64) uint32 { + prefix := []byte(fmt.Sprintf("%s%s:%020d:", prefixContractLog, contractID, blockHeight)) + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + var count uint32 + for it.Rewind(); it.Valid(); it.Next() { + count++ + } + return count +} + +// ─── Small helpers used by native contracts ────────────────────────────────── + +// Uint64 is a tiny helper for reading a uint64 stored as 8 big-endian bytes. +// (We deliberately don't use JSON for hot state keys.) +func unmarshalUint64(b []byte, dst *uint64) error { + if len(b) != 8 { + return fmt.Errorf("not a uint64") + } + *dst = uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 | + uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7]) + return nil +} + diff --git a/blockchain/native_username.go b/blockchain/native_username.go new file mode 100644 index 0000000..b383e4c --- /dev/null +++ b/blockchain/native_username.go @@ -0,0 +1,371 @@ +// Package blockchain — native username registry. +// +// Deterministic, in-process replacement for the WASM username_registry +// contract. Every node runs exactly the same Go code against the same +// BadgerDB txn, so state transitions are byte-identical across the network. +// +// Why native instead of WASM: +// - A single register() call via wazero takes ~10 ms; native takes ~50 µs. +// - No gas-metering edge cases (an opcode loop the listener misses would +// otherwise wedge AddBlock — which is how we wound up here). +// - We own the API surface — upgrades don't require re-deploying WASM +// and renegotiating the well-known contract_id. +// +// State layout (all keys prefixed with cstate:: by NativeContext helpers): +// +// name: → owner pubkey (raw hex bytes, 64 chars) +// addr: → name (raw UTF-8 bytes) +// meta:version → ABI version string (debug only) +// +// Methods: +// +// register(name) — claim a name; caller becomes owner +// resolve(name) — read-only, returns owner via log +// lookup(pub) — read-only, returns name via log +// transfer(name, new_owner_pub) — current owner transfers +// release(name) — current owner releases +// +// The same ABI JSON the WASM build exposes is reported here so the +// well-known endpoint + explorer work without modification. +package blockchain + +import ( + "encoding/json" + "fmt" + "strings" +) + +// UsernameRegistryID is the deterministic on-chain ID for the native +// username registry. We pin it to a readable short string instead of a +// hash because there is only ever one registry per chain, and a stable +// well-known ID makes debug URLs easier (/api/contracts/username_registry). +const UsernameRegistryID = "native:username_registry" + +// MinUsernameLength caps how short a name can be. Shorter names would be +// cheaper to register and quicker to grab, incentivising squatters. 4 is +// the sweet spot: long enough to avoid 2-char grabs, short enough to allow +// "alice" / "bob1" / common initials. +const MinUsernameLength = 4 + +// MaxUsernameLength is the upper bound. Anything longer is wasteful. +const MaxUsernameLength = 32 + +// UsernameRegistrationFee is a flat fee per register() call, in µT. Paid +// by the caller and burned (reduces total supply) — simpler than routing +// to a treasury account and avoids the "contract treasury" concept for +// the first native contract. +// +// 10_000 µT (0.01 T) is low enough for genuine users and high enough +// that a griefer can't squat thousands of names for nothing. +const UsernameRegistrationFee = 10_000 + +// usernameABI is returned by ABI(). Fields mirror the WASM registry's ABI +// JSON so the well-known endpoint / explorer discover it the same way. +const usernameABI = `{ + "contract": "username_registry", + "version": "2.1.0-native", + "description": "Maps human-readable usernames (min 4 chars, lowercase a-z 0-9 _ -, must start with a letter) to wallet addresses. register requires tx.amount = 10 000 µT which is burned.", + "methods": [ + {"name":"register","description":"Claim a username. Send tx.amount=10000 as the registration fee (burned). Caller becomes owner.","args":[{"name":"name","type":"string"}],"payable":10000}, + {"name":"resolve","description":"Look up owner address by name. Free (tx.amount=0).","args":[{"name":"name","type":"string"}]}, + {"name":"lookup","description":"Look up name by owner address. Free.","args":[{"name":"address","type":"string"}]}, + {"name":"transfer","description":"Transfer ownership to a new address. Free; only current owner may call.","args":[{"name":"name","type":"string"},{"name":"new_owner","type":"string"}]}, + {"name":"release","description":"Release a registered name. Free; only current owner may call.","args":[{"name":"name","type":"string"}]} + ] +}` + +// UsernameRegistry is the native implementation of the registry contract. +// Stateless — all state lives in the chain's BadgerDB txn passed via +// NativeContext on each call. +type UsernameRegistry struct{} + +// NewUsernameRegistry returns a contract ready to register with the chain. +func NewUsernameRegistry() *UsernameRegistry { return &UsernameRegistry{} } + +// Compile-time check that we satisfy the interface. +var _ NativeContract = (*UsernameRegistry)(nil) + +// ID implements NativeContract. +func (UsernameRegistry) ID() string { return UsernameRegistryID } + +// ABI implements NativeContract. +func (UsernameRegistry) ABI() string { return usernameABI } + +// Call implements NativeContract — dispatches to the per-method handlers. +// Gas cost is a flat 1_000 units per call (native is cheap, but we charge +// something so the fee mechanics match the WASM path). +func (r UsernameRegistry) Call(ctx *NativeContext, method string, argsJSON []byte) (uint64, error) { + const gasCost uint64 = 1_000 + + args, err := parseArgs(argsJSON) + if err != nil { + return gasCost, fmt.Errorf("%w: bad args: %v", ErrTxFailed, err) + } + + switch method { + case "register": + return gasCost, r.register(ctx, args) + case "resolve": + return gasCost, r.resolve(ctx, args) + case "lookup": + return gasCost, r.lookup(ctx, args) + case "transfer": + return gasCost, r.transfer(ctx, args) + case "release": + return gasCost, r.release(ctx, args) + default: + return gasCost, fmt.Errorf("%w: unknown method %q", ErrTxFailed, method) + } +} + +// ─── Method handlers ───────────────────────────────────────────────────────── + +// register claims a name for ctx.Caller. Preconditions: +// - name validates (length, charset, not reserved) +// - name is not already taken +// - caller has no existing registration (one-per-address rule) +// - tx.Amount (ctx.TxAmount) must be exactly UsernameRegistrationFee; +// that payment is debited from the caller and burned +// +// Pay-via-tx.Amount (instead of an invisible debit inside the contract) +// makes the cost explicit: the registration fee shows up as `amount_ut` +// in the transaction envelope and in the explorer, so callers know +// exactly what they paid. See the module-level doc for the full rationale. +// +// On success: +// - debit ctx.TxAmount from caller (burn — no recipient) +// - write name → caller pubkey mapping (key "name:") +// - write caller → name mapping (key "addr:") +// - emit `registered: ` log +func (UsernameRegistry) register(ctx *NativeContext, args []json.RawMessage) error { + name, err := argString(args, 0, "name") + if err != nil { + return err + } + if err := validateName(name); err != nil { + return err + } + + // Payment check — must be EXACTLY the registration fee. Under-payment + // is rejected (obvious); over-payment is also rejected to avoid + // accidental overpayment from a buggy client, and to keep the fee + // structure simple. A future `transfer` method may introduce other + // pricing. + if ctx.TxAmount != UsernameRegistrationFee { + return fmt.Errorf("%w: register requires tx.amount = %d µT (got %d µT)", + ErrTxFailed, UsernameRegistrationFee, ctx.TxAmount) + } + + // Already taken? + existing, err := ctx.Get("name:" + name) + if err != nil { + return err + } + if existing != nil { + return fmt.Errorf("%w: name %q already registered", ErrTxFailed, name) + } + + // Caller already has a name? + ownerKey := "addr:" + ctx.Caller + prior, err := ctx.Get(ownerKey) + if err != nil { + return err + } + if prior != nil { + return fmt.Errorf("%w: address already owns %q; release it first", ErrTxFailed, string(prior)) + } + + // Collect the registration fee (burn — no recipient). + if err := ctx.Debit(ctx.Caller, ctx.TxAmount); err != nil { + return fmt.Errorf("payment debit: %w", err) + } + + // Persist both directions. + if err := ctx.Set("name:"+name, []byte(ctx.Caller)); err != nil { + return err + } + if err := ctx.Set(ownerKey, []byte(name)); err != nil { + return err + } + + return ctx.Log("registered: " + name + " → " + ctx.Caller) +} + +func (UsernameRegistry) resolve(ctx *NativeContext, args []json.RawMessage) error { + name, err := argString(args, 0, "name") + if err != nil { + return err + } + val, err := ctx.Get("name:" + name) + if err != nil { + return err + } + if val == nil { + return ctx.Log("not found: " + name) + } + return ctx.Log("owner: " + string(val)) +} + +func (UsernameRegistry) lookup(ctx *NativeContext, args []json.RawMessage) error { + addr, err := argString(args, 0, "address") + if err != nil { + return err + } + val, err := ctx.Get("addr:" + addr) + if err != nil { + return err + } + if val == nil { + return ctx.Log("no name: " + addr) + } + return ctx.Log("name: " + string(val)) +} + +func (UsernameRegistry) transfer(ctx *NativeContext, args []json.RawMessage) error { + name, err := argString(args, 0, "name") + if err != nil { + return err + } + newOwner, err := argString(args, 1, "new_owner") + if err != nil { + return err + } + if err := validatePubKey(newOwner); err != nil { + return err + } + + cur, err := ctx.Get("name:" + name) + if err != nil { + return err + } + if cur == nil { + return fmt.Errorf("%w: name %q not registered", ErrTxFailed, name) + } + if string(cur) != ctx.Caller { + return fmt.Errorf("%w: only current owner can transfer", ErrTxFailed) + } + // New owner must not already have a name. + if existing, err := ctx.Get("addr:" + newOwner); err != nil { + return err + } else if existing != nil { + return fmt.Errorf("%w: new owner already owns %q", ErrTxFailed, string(existing)) + } + + // Update both directions. + if err := ctx.Set("name:"+name, []byte(newOwner)); err != nil { + return err + } + if err := ctx.Delete("addr:" + ctx.Caller); err != nil { + return err + } + if err := ctx.Set("addr:"+newOwner, []byte(name)); err != nil { + return err + } + + return ctx.Log("transferred: " + name + " → " + newOwner) +} + +func (UsernameRegistry) release(ctx *NativeContext, args []json.RawMessage) error { + name, err := argString(args, 0, "name") + if err != nil { + return err + } + cur, err := ctx.Get("name:" + name) + if err != nil { + return err + } + if cur == nil { + return fmt.Errorf("%w: name %q not registered", ErrTxFailed, name) + } + if string(cur) != ctx.Caller { + return fmt.Errorf("%w: only current owner can release", ErrTxFailed) + } + + if err := ctx.Delete("name:" + name); err != nil { + return err + } + if err := ctx.Delete("addr:" + ctx.Caller); err != nil { + return err + } + + return ctx.Log("released: " + name) +} + +// ─── Validation helpers ────────────────────────────────────────────────────── + +// validateName enforces our naming rules. Policies that appear here must +// match the client-side preview in settings.tsx: lowercase alphanumeric +// plus underscore/hyphen, length 4-32, cannot start with a digit or hyphen. +func validateName(name string) error { + if len(name) < MinUsernameLength { + return fmt.Errorf("%w: name too short: min %d chars", ErrTxFailed, MinUsernameLength) + } + if len(name) > MaxUsernameLength { + return fmt.Errorf("%w: name too long: max %d chars", ErrTxFailed, MaxUsernameLength) + } + // First char must be a-z (avoid leading digits, hyphens, underscores). + first := name[0] + if !(first >= 'a' && first <= 'z') { + return fmt.Errorf("%w: name must start with a letter a-z", ErrTxFailed) + } + for i := 0; i < len(name); i++ { + c := name[i] + switch { + case c >= 'a' && c <= 'z': + case c >= '0' && c <= '9': + case c == '_' || c == '-': + default: + return fmt.Errorf("%w: invalid character %q (lowercase letters, digits, _ and - only)", ErrTxFailed, c) + } + } + // Reserved names — clients that show system labels shouldn't be spoofable. + reserved := []string{"system", "admin", "root", "dchain", "null", "none"} + for _, r := range reserved { + if name == r { + return fmt.Errorf("%w: %q is reserved", ErrTxFailed, name) + } + } + return nil +} + +// validatePubKey accepts a 64-char lowercase hex string (Ed25519 pubkey). +func validatePubKey(s string) error { + if len(s) != 64 { + return fmt.Errorf("%w: pubkey must be 64 hex chars", ErrTxFailed) + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= '0' && c <= '9': + case c >= 'a' && c <= 'f': + default: + return fmt.Errorf("%w: pubkey has non-hex character", ErrTxFailed) + } + } + return nil +} + +// parseArgs turns the CallContractPayload.ArgsJSON string into a slice of +// raw JSON messages. Empty/whitespace-only input parses to an empty slice. +func parseArgs(argsJSON []byte) ([]json.RawMessage, error) { + if len(argsJSON) == 0 || strings.TrimSpace(string(argsJSON)) == "" { + return nil, nil + } + var out []json.RawMessage + if err := json.Unmarshal(argsJSON, &out); err != nil { + return nil, err + } + return out, nil +} + +// argString reads args[idx] as a JSON string and returns its value. +func argString(args []json.RawMessage, idx int, name string) (string, error) { + if idx >= len(args) { + return "", fmt.Errorf("%w: missing argument %q (index %d)", ErrTxFailed, name, idx) + } + var s string + if err := json.Unmarshal(args[idx], &s); err != nil { + return "", fmt.Errorf("%w: argument %q must be a string", ErrTxFailed, name) + } + return strings.TrimSpace(s), nil +} diff --git a/blockchain/schema_migrations.go b/blockchain/schema_migrations.go new file mode 100644 index 0000000..c21f0e9 --- /dev/null +++ b/blockchain/schema_migrations.go @@ -0,0 +1,197 @@ +// Package blockchain — BadgerDB schema version tracking + migration scaffold. +// +// Why this exists +// ─────────────── +// The chain's on-disk layout is a flat KV store with string-prefixed keys +// (see chain.go: prefixBalance, prefixChannel, etc.). Every breaking change +// to those prefixes or value shapes would otherwise require operators to +// wipe their volume and re-sync from scratch. That's painful at 10 nodes; +// catastrophic at 1000. +// +// This file introduces a single meta-key — `schema:ver` → uint32 — that +// records the layout version the data was written in. On every chain open: +// +// 1. We read the current version (0 if missing = fresh DB or pre-migration). +// 2. We iterate forward, running each migration[k→k+1] in order, bumping +// the stored version after each successful step. +// 3. If CurrentSchemaVersion is already reached, zero migrations run, the +// call is ~1 µs (single KV read). +// +// Design principles +// ──────────────── +// • Idempotent: a crashed migration can be re-run from scratch. Every +// migration either completes its write AND updates the version in the +// SAME transaction, or neither. +// • Forward-only: downgrade is not supported. If an operator needs to +// roll back the binary, they restore from a pre-upgrade backup. The +// `update.sh` operator script checkpoints before restart for this. +// • Tiny: the migration registry is a plain Go slice, not a framework. +// Each migration is ~20 lines. Adding one is purely additive. +// +// As of this commit there are ZERO migrations (CurrentSchemaVersion = 0). +// The scaffolding ships empty so the very first real migration — whenever +// it lands — has a home that all deployed nodes already understand. +package blockchain + +import ( + "encoding/binary" + "fmt" + "log" + + badger "github.com/dgraph-io/badger/v4" +) + +const ( + // schemaMetaKey is the single BadgerDB key that stores this DB's current + // schema version. Not prefixed like other keys — it's a bootstrap marker + // read before any prefixed query, so conflicts with userland prefixes + // are impossible by construction. + schemaMetaKey = "schema:ver" + + // CurrentSchemaVersion is the layout this binary writes. Bumped in lockstep + // with every migration added below. A fresh DB is written at this version + // directly (no migration chain to run). + CurrentSchemaVersion uint32 = 0 +) + +// migration represents a single step from version v to v+1. +// Apply runs inside a single badger.Update — if it returns error, nothing +// is written, and the migration can be safely retried. +type migration struct { + From uint32 + To uint32 + Description string + Apply func(txn *badger.Txn) error +} + +// migrations is the ordered forward-migration registry. +// +// To add a migration: +// +// 1. Bump CurrentSchemaVersion above. +// 2. Append an entry here with From = previous, To = new. +// 3. In Apply, walk the relevant prefixes and rewrite keys/values. +// 4. Add a unit test in schema_migrations_test.go seeding a vN-1 DB +// and asserting the vN invariants after one NewChain open. +// +// The slice is intentionally empty right now: the scaffold ships first, +// migrations land per-feature as needed. +var migrations = []migration{ + // no migrations yet +} + +// readSchemaVersion returns the version stored at schemaMetaKey, or 0 if the +// key is absent (interpretation: "pre-migration DB / fresh DB treat as v0"). +func readSchemaVersion(db *badger.DB) (uint32, error) { + var v uint32 + err := db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(schemaMetaKey)) + if err == badger.ErrKeyNotFound { + v = 0 + return nil + } + if err != nil { + return err + } + return item.Value(func(val []byte) error { + if len(val) != 4 { + return fmt.Errorf("schema version has unexpected length %d (want 4)", len(val)) + } + v = binary.BigEndian.Uint32(val) + return nil + }) + }) + return v, err +} + +// writeSchemaVersion persists the given version under schemaMetaKey. Usually +// called inside the same txn that applied the corresponding migration, so +// version bump + data rewrite are atomic. runMigrations handles that. +func writeSchemaVersion(txn *badger.Txn, v uint32) error { + var buf [4]byte + binary.BigEndian.PutUint32(buf[:], v) + return txn.Set([]byte(schemaMetaKey), buf[:]) +} + +// runMigrations applies every registered migration forward from the stored +// version to CurrentSchemaVersion. Called by NewChain after badger.Open. +// +// Behavior: +// - stored == target → no-op, returns nil +// - stored < target → runs each migration[k→k+1] in sequence; if ANY +// returns error, the DB is left at the last successful version and the +// error is returned (no partial-migration corruption). +// - stored > target → FATAL: operator is running an older binary on a +// newer DB. Refuse to open rather than silently mis-interpret data. +func runMigrations(db *badger.DB) error { + cur, err := readSchemaVersion(db) + if err != nil { + return fmt.Errorf("read schema version: %w", err) + } + if cur == CurrentSchemaVersion { + return nil + } + if cur > CurrentSchemaVersion { + return fmt.Errorf( + "chain DB is at schema v%d but this binary only understands v%d — "+ + "run a newer binary OR restore from a pre-upgrade backup", + cur, CurrentSchemaVersion) + } + + log.Printf("[CHAIN] migrating schema v%d → v%d (%d steps)", + cur, CurrentSchemaVersion, CurrentSchemaVersion-cur) + + for _, m := range migrations { + if m.From < cur { + continue + } + if m.From != cur { + return fmt.Errorf("migration gap: stored=v%d, next migration expects v%d", + cur, m.From) + } + if m.To != m.From+1 { + return fmt.Errorf("migration %d→%d is not a single step", m.From, m.To) + } + log.Printf("[CHAIN] migration v%d→v%d: %s", m.From, m.To, m.Description) + err := db.Update(func(txn *badger.Txn) error { + if err := m.Apply(txn); err != nil { + return err + } + return writeSchemaVersion(txn, m.To) + }) + if err != nil { + return fmt.Errorf("migration v%d→v%d failed: %w", m.From, m.To, err) + } + cur = m.To + } + + // Fresh DB with no migrations yet to run — stamp the current version so + // we don't re-read "0 = no key" forever on later opens. + if cur < CurrentSchemaVersion { + err := db.Update(func(txn *badger.Txn) error { + return writeSchemaVersion(txn, CurrentSchemaVersion) + }) + if err != nil { + return fmt.Errorf("stamp schema version %d: %w", CurrentSchemaVersion, err) + } + } + + // On a brand-new DB (no chain yet) cur is still 0 but + // CurrentSchemaVersion is also 0 (today), so nothing to stamp. When the + // first real migration lands, this stamp becomes active. + if CurrentSchemaVersion == 0 && cur == 0 { + err := db.Update(func(txn *badger.Txn) error { + // Only stamp if the key is absent — otherwise we already wrote it + // in the loop above. + if _, getErr := txn.Get([]byte(schemaMetaKey)); getErr == badger.ErrKeyNotFound { + return writeSchemaVersion(txn, CurrentSchemaVersion) + } + return nil + }) + if err != nil { + return fmt.Errorf("stamp initial schema version 0: %w", err) + } + } + + return nil +} diff --git a/blockchain/types.go b/blockchain/types.go new file mode 100644 index 0000000..471d413 --- /dev/null +++ b/blockchain/types.go @@ -0,0 +1,509 @@ +package blockchain + +import ( + "crypto/sha256" + "encoding/binary" + "time" +) + +// EventType defines what kind of event a transaction represents. +type EventType string + +const ( + EventRegisterKey EventType = "REGISTER_KEY" + EventCreateChannel EventType = "CREATE_CHANNEL" + EventAddMember EventType = "ADD_MEMBER" + EventOpenPayChan EventType = "OPEN_PAY_CHAN" + EventClosePayChan EventType = "CLOSE_PAY_CHAN" + EventTransfer EventType = "TRANSFER" + EventRelayProof EventType = "RELAY_PROOF" + EventRegisterRelay EventType = "REGISTER_RELAY" // node advertises relay service + EventBindWallet EventType = "BIND_WALLET" // node binds a payout wallet address + EventSlash EventType = "SLASH" // penalise a misbehaving validator + EventHeartbeat EventType = "HEARTBEAT" // liveness ping from a node + EventBlockReward EventType = "BLOCK_REWARD" // synthetic tx indexed on block commit + EventContactRequest EventType = "CONTACT_REQUEST" // paid first-contact request (ICQ-style) + EventAcceptContact EventType = "ACCEPT_CONTACT" // recipient accepts a pending request + EventBlockContact EventType = "BLOCK_CONTACT" // recipient blocks a sender + EventAddValidator EventType = "ADD_VALIDATOR" // existing validator adds a new one + EventRemoveValidator EventType = "REMOVE_VALIDATOR" // existing validator removes one (or self-removal) + EventDeployContract EventType = "DEPLOY_CONTRACT" // deploy a WASM smart contract + EventCallContract EventType = "CALL_CONTRACT" // call a method on a deployed contract + EventStake EventType = "STAKE" // lock tokens as validator stake + EventUnstake EventType = "UNSTAKE" // release staked tokens back to balance + EventIssueToken EventType = "ISSUE_TOKEN" // create a new fungible token + EventTransferToken EventType = "TRANSFER_TOKEN" // transfer fungible tokens between addresses + EventBurnToken EventType = "BURN_TOKEN" // destroy fungible tokens + EventMintNFT EventType = "MINT_NFT" // mint a new non-fungible token + EventTransferNFT EventType = "TRANSFER_NFT" // transfer NFT ownership + EventBurnNFT EventType = "BURN_NFT" // burn (destroy) an NFT +) + +// Token amounts are stored in micro-tokens (µT). +// 1 token = 1_000_000 µT +const ( + MicroToken uint64 = 1 + Token uint64 = 1_000_000 + + // MinFee is the minimum transaction fee paid to the block validator. + // Validators earn fees as their only income — no block reward minting. + MinFee uint64 = 1_000 // 0.001 T per transaction + + // GenesisAllocation is a one-time mint at block 0 for the bootstrap validator. + // All subsequent token supply comes only from re-distribution of existing balances. + GenesisAllocation uint64 = 21_000_000 * Token // 21 million T, fixed supply + + // SlashAmount is the penalty deducted from a misbehaving validator's balance. + SlashAmount uint64 = 50 * Token + + // RegistrationFee is the one-time fee to register an identity on-chain + // (EventRegisterKey). Paid to the block validator. High enough to deter + // Sybil attacks while remaining affordable. + RegistrationFee uint64 = 1_000_000 // 1 T + + // MinContactFee is the minimum amount a sender must pay the recipient when + // submitting an EventContactRequest (anti-spam; goes directly to recipient). + MinContactFee uint64 = 5_000 // 0.005 T +) + +// Transaction is the atomic unit recorded in a block. +// Bodies of messages are NEVER stored here — only identity/channel events. +type Transaction struct { + ID string `json:"id"` + Type EventType `json:"type"` + From string `json:"from"` // hex-encoded Ed25519 public key + To string `json:"to"` // hex-encoded Ed25519 public key (if applicable) + Amount uint64 `json:"amount"` // µT to transfer (for TRANSFER type) + Fee uint64 `json:"fee"` // µT paid to the block validator + Memo string `json:"memo,omitempty"` + Payload []byte `json:"payload"` // JSON-encoded event-specific data + Signature []byte `json:"signature"` // Ed25519 sig over canonical bytes + Timestamp time.Time `json:"timestamp"` +} + +// RegisterKeyPayload is embedded in EventRegisterKey transactions. +type RegisterKeyPayload struct { + PubKey string `json:"pub_key"` // hex-encoded Ed25519 public key + Nickname string `json:"nickname"` // human-readable, non-unique + PowNonce uint64 `json:"pow_nonce"` // proof-of-work nonce (Sybil barrier) + PowTarget string `json:"pow_target"` + X25519PubKey string `json:"x25519_pub_key,omitempty"` // hex Curve25519 key for E2E messaging +} + +// CreateChannelPayload is embedded in EventCreateChannel transactions. +type CreateChannelPayload struct { + ChannelID string `json:"channel_id"` + Title string `json:"title"` + IsPublic bool `json:"is_public"` +} + +// RegisterRelayPayload is embedded in EventRegisterRelay transactions. +// A node publishes this to advertise itself as a relay service provider. +// Clients look up relay nodes via GET /api/relays. +type RegisterRelayPayload struct { + // X25519PubKey is the hex-encoded Curve25519 public key for NaCl envelope encryption. + // Senders use this key to seal messages addressed to this relay node. + X25519PubKey string `json:"x25519_pub_key"` + // FeePerMsgUT is the relay fee the node charges per delivered envelope (in µT). + FeePerMsgUT uint64 `json:"fee_per_msg_ut"` + // Multiaddr is the optional libp2p multiaddr string for direct connections. + Multiaddr string `json:"multiaddr,omitempty"` +} + +// RelayProofPayload proves that a relay/recipient node received an envelope. +// The sender pre-authorises the fee by signing FeeAuthBytes(EnvelopeID, FeeUT). +// On-chain the fee is pulled from the sender's balance and credited to the relay. +type RelayProofPayload struct { + // EnvelopeID is the stable identifier of the delivered envelope (hex). + EnvelopeID string `json:"envelope_id"` + // EnvelopeHash is SHA-256(nonce || ciphertext) — prevents double-claiming. + EnvelopeHash []byte `json:"envelope_hash"` + // SenderPubKey is the Ed25519 public key of the envelope sender (hex). + SenderPubKey string `json:"sender_pub_key"` + // FeeUT is the delivery fee the relay claims from the sender's balance. + FeeUT uint64 `json:"fee_ut"` + // FeeSig is the sender's Ed25519 signature over FeeAuthBytes(EnvelopeID, FeeUT). + // This authorises the relay to pull FeeUT from the sender's on-chain balance. + FeeSig []byte `json:"fee_sig"` + // RelayPubKey is the Ed25519 public key of the relay claiming the fee (hex). + RelayPubKey string `json:"relay_pub_key"` + // DeliveredAt is the unix timestamp of delivery. + DeliveredAt int64 `json:"delivered_at"` + // RecipientSig is the recipient's optional Ed25519 sig over EnvelopeHash, + // proving the message was successfully decrypted (not required for fee claim). + RecipientSig []byte `json:"recipient_sig,omitempty"` +} + +// FeeAuthBytes returns the canonical byte string that the sender must sign +// to pre-authorise a relay fee pull. The relay includes this signature in +// RelayProofPayload.FeeSig when submitting the proof on-chain. +// +// Format: SHA-256("relay-fee:" || envelopeID || uint64BE(feeUT)) +func FeeAuthBytes(envelopeID string, feeUT uint64) []byte { + h := sha256.New() + h.Write([]byte("relay-fee:")) + h.Write([]byte(envelopeID)) + var b [8]byte + binary.BigEndian.PutUint64(b[:], feeUT) + h.Write(b[:]) + return h.Sum(nil) +} + +// TransferPayload carries an optional memo for token transfers. +type TransferPayload struct { + Memo string `json:"memo,omitempty"` +} + +// BindWalletPayload links a node's signing key to a separate payout wallet. +// After this tx is committed, block fees and relay fees are credited to +// WalletPubKey instead of the node's own pub key. +type BindWalletPayload struct { + WalletPubKey string `json:"wallet_pub_key"` + WalletAddr string `json:"wallet_addr"` +} + +// SlashPayload is submitted by a validator to penalise a misbehaving peer. +type SlashPayload struct { + OffenderPubKey string `json:"offender_pub_key"` + Reason string `json:"reason"` // "double_vote" | "downtime" | "equivocation" + Evidence []byte `json:"evidence,omitempty"` +} + +// HeartbeatPayload is a periodic liveness signal published by active nodes. +// It carries the node's current chain height so peers can detect lagging nodes. +// Heartbeats cost MinFee (paid to the block validator) and earn no reward — +// they exist to build reputation and prove liveness. +type HeartbeatPayload struct { + PubKey string `json:"pub_key"` + ChainHeight uint64 `json:"chain_height"` + PeerCount int `json:"peer_count"` + Version string `json:"version"` +} + +// OpenPayChanPayload locks deposits from two parties into a payment channel. +type OpenPayChanPayload struct { + ChannelID string `json:"channel_id"` + PartyA string `json:"party_a"` + PartyB string `json:"party_b"` + DepositA uint64 `json:"deposit_a_ut"` + DepositB uint64 `json:"deposit_b_ut"` + ExpiryBlock uint64 `json:"expiry_block"` + SigB []byte `json:"sig_b"` // PartyB's Ed25519 sig over channel params +} + +// ClosePayChanPayload settles a payment channel and distributes balances. +type ClosePayChanPayload struct { + ChannelID string `json:"channel_id"` + BalanceA uint64 `json:"balance_a_ut"` + BalanceB uint64 `json:"balance_b_ut"` + Nonce uint64 `json:"nonce"` + SigA []byte `json:"sig_a"` + SigB []byte `json:"sig_b"` +} + +// PayChanState is stored on-chain for each open payment channel. +type PayChanState struct { + ChannelID string `json:"channel_id"` + PartyA string `json:"party_a"` + PartyB string `json:"party_b"` + DepositA uint64 `json:"deposit_a_ut"` + DepositB uint64 `json:"deposit_b_ut"` + ExpiryBlock uint64 `json:"expiry_block"` + OpenedBlock uint64 `json:"opened_block"` + Nonce uint64 `json:"nonce"` + Closed bool `json:"closed"` +} + +// BlockRewardPayload is attached to synthetic BLOCK_REWARD transactions. +// These are index-only records so the explorer can show validator fee income. +// There is no minting — the FeeReward comes from existing transaction fees. +type BlockRewardPayload struct { + ValidatorPubKey string `json:"validator_pub_key"` + TargetPubKey string `json:"target_pub_key"` + FeeReward uint64 `json:"fee_reward_ut"` + TotalReward uint64 `json:"total_reward_ut"` +} + +// ContactRequestPayload is embedded in EventContactRequest transactions. +// The sender pays tx.Amount directly to the recipient (anti-spam fee). +// A pending contact record is stored on-chain for the recipient to accept or block. +type ContactRequestPayload struct { + Intro string `json:"intro,omitempty"` // optional plaintext intro (≤ 280 chars) +} + +// AcceptContactPayload is embedded in EventAcceptContact transactions. +// tx.From accepts a pending request from tx.To. +type AcceptContactPayload struct{} + +// BlockContactPayload is embedded in EventBlockContact transactions. +// tx.From blocks tx.To; future contact requests from tx.To are rejected. +type BlockContactPayload struct { + Reason string `json:"reason,omitempty"` +} + +// ChannelMember records a participant in a channel together with their +// X25519 public key. The key is cached on-chain (written during ADD_MEMBER) +// so channel senders don't have to fan out a separate /api/identity lookup +// per recipient on every message — they GET /api/channels/:id/members +// once and seal N envelopes in a loop. +type ChannelMember struct { + PubKey string `json:"pub_key"` // Ed25519 hex + X25519PubKey string `json:"x25519_pub_key"` // optional; empty if member hasn't registered + Address string `json:"address"` +} + +// AddMemberPayload is embedded in EventAddMember transactions. +// tx.From adds tx.To as a member of the specified channel. +// If tx.To is empty, tx.From is added (self-join for public channels). +type AddMemberPayload struct { + ChannelID string `json:"channel_id"` +} + +// AddValidatorPayload is embedded in EventAddValidator transactions. +// tx.From must already be a validator; tx.To is the new validator's pub key. +// +// Admission is gated by two things: +// 1. Stake: the candidate (tx.To) must have STAKE'd at least +// MinValidatorStake beforehand. Prevents anyone spinning up a free +// validator without economic buy-in. +// 2. Multi-sig: at least ⌈2/3⌉ of the CURRENT validator set must approve. +// The tx sender counts as one; remaining approvals go in CoSignatures. +// For a 1-validator chain (fresh genesis / tests) sender alone is 2/3, +// so CoSignatures can be empty — backward-compat is preserved. +type AddValidatorPayload struct { + Reason string `json:"reason,omitempty"` + CoSignatures []ValidatorCoSig `json:"cosigs,omitempty"` +} + +// ValidatorCoSig is an off-chain-assembled approval from one existing +// validator for a specific candidate admission. The signature is over the +// canonical digest returned by AdmitDigest(candidatePubKeyHex). +type ValidatorCoSig struct { + PubKey string `json:"pubkey"` // Ed25519 hex of a current validator + Signature []byte `json:"signature"` // Ed25519 signature over AdmitDigest(candidate) +} + +// AdmitDigest returns the canonical bytes a validator signs to approve +// admitting `candidatePubHex` as a new validator. Stable across implementations +// so co-sigs collected off-chain verify identically on-chain. +func AdmitDigest(candidatePubHex string) []byte { + h := sha256.New() + h.Write([]byte("DCHAIN-ADD-VALIDATOR\x00")) + h.Write([]byte(candidatePubHex)) + return h.Sum(nil) +} + +// MinValidatorStake is the minimum µT a candidate must have locked in +// `stake:` before an ADD_VALIDATOR naming them is accepted. +// 1 T = 1_000_000 µT — small enough that testnets can afford it easily, +// large enough to deter "register 100 fake validators to 51%-attack". +const MinValidatorStake uint64 = 1_000_000 + +// RemoveValidatorPayload is embedded in EventRemoveValidator transactions. +// tx.From must be a validator; tx.To is the validator to remove. +// +// Two legitimate use cases: +// 1. Self-removal (tx.From == tx.To): always allowed, no cosigs needed. +// Lets a validator gracefully leave the set without requiring others. +// 2. Forced removal (tx.From != tx.To): requires ⌈2/3⌉ cosigs of the +// current validator set — same pattern as ADD_VALIDATOR. Stops a +// single validator from unilaterally kicking peers. +// +// The signed payload is AdmitDigest(tx.To) but with the domain byte flipped +// — see RemoveDigest below. This prevents a cosig collected for "admit X" +// from being replayed as "remove X". +type RemoveValidatorPayload struct { + Reason string `json:"reason,omitempty"` + CoSignatures []ValidatorCoSig `json:"cosigs,omitempty"` +} + +// RemoveDigest is the canonical bytes a validator signs to approve removing +// `targetPubHex` from the set. Distinct from AdmitDigest so signatures +// can't be cross-replayed between add and remove operations. +func RemoveDigest(targetPubHex string) []byte { + h := sha256.New() + h.Write([]byte("DCHAIN-REMOVE-VALIDATOR\x00")) + h.Write([]byte(targetPubHex)) + return h.Sum(nil) +} + +// DeployContractPayload is embedded in EventDeployContract transactions. +// WASMBase64 is the base64-encoded WASM binary. It is stored in the tx so that +// nodes can replay the chain from genesis and re-derive contract state. +type DeployContractPayload struct { + WASMBase64 string `json:"wasm_b64"` + ABIJson string `json:"abi_json"` + InitArgs string `json:"init_args_json,omitempty"` +} + +// CallContractPayload is embedded in EventCallContract transactions. +type CallContractPayload struct { + ContractID string `json:"contract_id"` + Method string `json:"method"` + ArgsJSON string `json:"args_json,omitempty"` + GasLimit uint64 `json:"gas_limit"` +} + +// ContractRecord is stored in BadgerDB at contract:. +// WASMBytes is NOT in the block; it is derived from the deploy tx payload on replay. +type ContractRecord struct { + ContractID string `json:"contract_id"` + WASMBytes []byte `json:"wasm_bytes"` + ABIJson string `json:"abi_json"` + DeployerPub string `json:"deployer_pub"` + DeployedAt uint64 `json:"deployed_at"` // block height +} + +// MinDeployFee is the minimum fee for a DEPLOY_CONTRACT transaction. +// Covers storage costs for the WASM binary. +const MinDeployFee uint64 = 10_000 // 0.01 T + +// MinCallFee is the minimum base fee for a CALL_CONTRACT transaction. +// Gas costs are billed on top of this. +const MinCallFee uint64 = MinFee + +// ContractLogEntry is one log message emitted by a contract via env.log(). +// Stored in BadgerDB at clog:::. +type ContractLogEntry struct { + ContractID string `json:"contract_id"` + BlockHeight uint64 `json:"block_height"` + TxID string `json:"tx_id"` + Seq int `json:"seq"` + Message string `json:"message"` +} + +// GasPrice is the cost in µT per 1 gas unit consumed during contract execution. +const GasPrice uint64 = 1 // 1 µT per gas unit + +// MinStake is the minimum amount a validator must stake. +const MinStake uint64 = 1_000 * Token // 1000 T + +// MinIssueTokenFee is the fee required to issue a new token. +const MinIssueTokenFee uint64 = 100_000 // 0.1 T + +// StakePayload is embedded in EventStake transactions. +// tx.Amount holds the amount to stake; tx.Fee is the transaction fee. +type StakePayload struct{} + +// UnstakePayload is embedded in EventUnstake transactions. +// The entire current stake is returned to the staker's balance. +type UnstakePayload struct{} + +// IssueTokenPayload is embedded in EventIssueToken transactions. +// The new token is credited to tx.From with TotalSupply units. +type IssueTokenPayload struct { + Name string `json:"name"` // human-readable token name, e.g. "My Token" + Symbol string `json:"symbol"` // ticker symbol, e.g. "MTK" + Decimals uint8 `json:"decimals"` // decimal places, e.g. 6 → 1 token = 1_000_000 base units + TotalSupply uint64 `json:"total_supply"` // initial supply in base units +} + +// TransferTokenPayload is embedded in EventTransferToken transactions. +// tx.To is the recipient; tx.Amount is ignored (use payload Amount). +type TransferTokenPayload struct { + TokenID string `json:"token_id"` + Amount uint64 `json:"amount"` // in base units +} + +// BurnTokenPayload is embedded in EventBurnToken transactions. +type BurnTokenPayload struct { + TokenID string `json:"token_id"` + Amount uint64 `json:"amount"` // in base units +} + +// TokenRecord is stored in BadgerDB at token:. +type TokenRecord struct { + TokenID string `json:"token_id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Decimals uint8 `json:"decimals"` + TotalSupply uint64 `json:"total_supply"` // current (may decrease via burns) + Issuer string `json:"issuer"` // creator pubkey + IssuedAt uint64 `json:"issued_at"` // block height +} + +// MinMintNFTFee is the fee required to mint a new NFT. +const MinMintNFTFee uint64 = 10_000 // 0.01 T + +// MintNFTPayload is embedded in EventMintNFT transactions. +type MintNFTPayload struct { + Name string `json:"name"` // human-readable name + Description string `json:"description,omitempty"` + URI string `json:"uri,omitempty"` // off-chain metadata URI (IPFS, https, etc.) + Attributes string `json:"attributes,omitempty"` // JSON string of trait attributes +} + +// TransferNFTPayload is embedded in EventTransferNFT transactions. +// tx.To is the new owner; tx.From must be current owner. +type TransferNFTPayload struct { + NFTID string `json:"nft_id"` +} + +// BurnNFTPayload is embedded in EventBurnNFT transactions. +type BurnNFTPayload struct { + NFTID string `json:"nft_id"` +} + +// NFTRecord is stored in BadgerDB at nft:. +type NFTRecord struct { + NFTID string `json:"nft_id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + URI string `json:"uri,omitempty"` + Attributes string `json:"attributes,omitempty"` + Owner string `json:"owner"` // current owner pubkey + Issuer string `json:"issuer"` // original minter pubkey + MintedAt uint64 `json:"minted_at"` // block height + Burned bool `json:"burned,omitempty"` +} + +// ContactStatus is the state of a contact relationship. +type ContactStatus string + +const ( + ContactPending ContactStatus = "pending" + ContactAccepted ContactStatus = "accepted" + ContactBlocked ContactStatus = "blocked" +) + +// ContactInfo is returned by the contacts API. +type ContactInfo struct { + RequesterPub string `json:"requester_pub"` + RequesterAddr string `json:"requester_addr"` + Status ContactStatus `json:"status"` + Intro string `json:"intro,omitempty"` + FeeUT uint64 `json:"fee_ut"` + TxID string `json:"tx_id"` + CreatedAt int64 `json:"created_at"` +} + +// IdentityInfo is returned by GET /api/identity/{pubkey}. +type IdentityInfo struct { + PubKey string `json:"pub_key"` + Address string `json:"address"` + X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published + Nickname string `json:"nickname"` + Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed +} + +// ConsensusMessage types used by the PBFT engine over the P2P layer. +type MsgType string + +const ( + MsgPrePrepare MsgType = "PRE_PREPARE" + MsgPrepare MsgType = "PREPARE" + MsgCommit MsgType = "COMMIT" + MsgViewChange MsgType = "VIEW_CHANGE" + MsgNewView MsgType = "NEW_VIEW" +) + +// ConsensusMsg is the envelope sent between validators. +type ConsensusMsg struct { + Type MsgType `json:"type"` + View uint64 `json:"view"` + SeqNum uint64 `json:"seq_num"` + BlockHash []byte `json:"block_hash"` + Block *Block `json:"block,omitempty"` + From string `json:"from"` + Signature []byte `json:"signature"` +} diff --git a/client-app/.gitignore b/client-app/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/client-app/README.md b/client-app/README.md new file mode 100644 index 0000000..856ba71 --- /dev/null +++ b/client-app/README.md @@ -0,0 +1,93 @@ +# DChain Messenger — React Native Client + +E2E-encrypted mobile/desktop messenger built on the DChain blockchain stack. + +**Stack:** React Native · Expo · NativeWind (Tailwind) · TweetNaCl · Zustand + +## Quick Start + +```bash +cd client-app +npm install +npx expo start # opens Expo Dev Tools +# Press 'i' for iOS simulator, 'a' for Android, 'w' for web +``` + +## Requirements + +- Node.js 18+ +- [Expo Go](https://expo.dev/client) on your phone (for Expo tunnel), or iOS/Android emulator +- A running DChain node (see root README for `docker compose up --build -d`) + +## Project Structure + +``` +client-app/ +├── app/ +│ ├── _layout.tsx # Root layout — loads keys, sets up nav +│ ├── index.tsx # Welcome / onboarding +│ ├── (auth)/ +│ │ ├── create.tsx # Generate new Ed25519 + X25519 keys +│ │ ├── created.tsx # Key created — export reminder +│ │ └── import.tsx # Import existing key.json +│ └── (app)/ +│ ├── _layout.tsx # Tab bar — Chats · Wallet · Settings +│ ├── chats/ +│ │ ├── index.tsx # Chat list with contacts +│ │ └── [id].tsx # Individual chat with E2E encryption +│ ├── requests.tsx # Incoming contact requests +│ ├── new-contact.tsx # Add contact by @username or address +│ ├── wallet.tsx # Balance + TX history + send +│ └── settings.tsx # Node URL, key export, profile +├── components/ui/ # shadcn-style components (Button, Card, Input…) +├── hooks/ +│ ├── useMessages.ts # Poll relay inbox, decrypt messages +│ ├── useBalance.ts # Poll token balance +│ └── useContacts.ts # Load contacts + poll contact requests +└── lib/ + ├── api.ts # REST client for all DChain endpoints + ├── crypto.ts # NaCl box encrypt/decrypt, Ed25519 sign + ├── storage.ts # SecureStore (keys) + AsyncStorage (data) + ├── store.ts # Zustand global state + ├── types.ts # TypeScript interfaces + └── utils.ts # cn(), formatAmount(), relativeTime() +``` + +## Cryptography + +| Operation | Algorithm | Library | +|-----------|-----------|---------| +| Transaction signing | Ed25519 | TweetNaCl `sign` | +| Key exchange | X25519 (Curve25519) | TweetNaCl `box` | +| Message encryption | NaCl box (XSalsa20-Poly1305) | TweetNaCl `box` | +| Key storage | Device secure enclave | expo-secure-store | + +Messages are encrypted as: +``` +Envelope { + sender_pub: // sender's public key + recipient_pub: // recipient's public key + nonce: <24-byte hex> // random per message + ciphertext: // NaCl box(plaintext, nonce, sender_priv, recipient_pub) +} +``` + +## Connect to your node + +1. Start the DChain node: `docker compose up --build -d` +2. Open the app → Settings → Node URL → `http://YOUR_IP:8081` +3. If using Expo Go on physical device: your PC and phone must be on the same network, or use `npx expo start --tunnel` + +## Key File Format + +The `key.json` exported/imported by the app: +```json +{ + "pub_key": "26018d40...", // Ed25519 public key (64 hex chars) + "priv_key": "...", // Ed25519 private key (128 hex chars) + "x25519_pub": "...", // X25519 public key (64 hex chars) + "x25519_priv": "..." // X25519 private key (64 hex chars) +} +``` + +This is the same format as the Go node's `--key` flag. diff --git a/client-app/app.json b/client-app/app.json new file mode 100644 index 0000000..e76123c --- /dev/null +++ b/client-app/app.json @@ -0,0 +1,36 @@ +{ + "expo": { + "name": "DChain Messenger", + "slug": "dchain-messenger", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "dark", + "backgroundColor": "#0d1117", + "ios": { + "supportsTablet": false, + "bundleIdentifier": "com.dchain.messenger" + }, + "android": { + "package": "com.dchain.messenger", + "softwareKeyboardLayoutMode": "pan" + }, + "web": { + "bundler": "metro", + "output": "static" + }, + "plugins": [ + "expo-router", + "expo-secure-store", + [ + "expo-camera", + { + "cameraPermission": "Allow DChain to scan QR codes for node configuration." + } + ] + ], + "experiments": { + "typedRoutes": false + }, + "scheme": "dchain" + } +} diff --git a/client-app/app/(app)/_layout.tsx b/client-app/app/(app)/_layout.tsx new file mode 100644 index 0000000..627d7c2 --- /dev/null +++ b/client-app/app/(app)/_layout.tsx @@ -0,0 +1,127 @@ +/** + * Main app tab layout. + * Redirects to welcome if no key found. + */ + +import React, { useEffect } from 'react'; +import { Tabs, router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { useBalance } from '@/hooks/useBalance'; +import { useContacts } from '@/hooks/useContacts'; +import { useWellKnownContracts } from '@/hooks/useWellKnownContracts'; +import { getWSClient } from '@/lib/ws'; + +const C_ACCENT = '#7db5ff'; +const C_MUTED = '#98a7c2'; +const C_BG = '#111a2b'; +const C_BORDER = '#1c2840'; + +export default function AppLayout() { + const keyFile = useStore(s => s.keyFile); + const requests = useStore(s => s.requests); + const insets = useSafeAreaInsets(); + + useBalance(); + useContacts(); + useWellKnownContracts(); // auto-discover canonical system contracts from node + + // Arm the WS client with this user's Ed25519 keypair. The client signs the + // server's auth nonce on every (re)connect so scoped subscriptions + // (addr:, inbox:) are accepted. Without this the + // server would still accept global topic subs but reject scoped ones. + useEffect(() => { + const ws = getWSClient(); + if (keyFile) { + ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key }); + } else { + ws.setAuthCreds(null); + } + }, [keyFile]); + + useEffect(() => { + if (keyFile === null) { + const t = setTimeout(() => { + if (!useStore.getState().keyFile) router.replace('/'); + }, 300); + return () => clearTimeout(t); + } + }, [keyFile]); + + // Tab bar layout math: + // icon (22) + gap (4) + label (~13) = ~39px of content + // We add a 12px visual margin above, and pad the bottom by the larger of + // the platform safe-area inset or 10px so the bar never sits flush on the + // home indicator. + const BAR_CONTENT_HEIGHT = 52; + const bottomPad = Math.max(insets.bottom, 10); + + return ( + + ( + + ), + tabBarBadge: requests.length > 0 ? requests.length : undefined, + tabBarBadgeStyle: { backgroundColor: C_ACCENT, fontSize: 10 }, + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + {/* Non-tab screens — hidden from tab bar */} + + + + ); +} diff --git a/client-app/app/(app)/chats/[id].tsx b/client-app/app/(app)/chats/[id].tsx new file mode 100644 index 0000000..62d862d --- /dev/null +++ b/client-app/app/(app)/chats/[id].tsx @@ -0,0 +1,413 @@ +/** + * Chat view — DChain messenger. + * Safe-area aware header/input, smooth scroll, proper E2E indicators, + * responsive send button with press feedback. + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { + View, Text, FlatList, TextInput, TouchableOpacity, Pressable, + KeyboardAvoidingView, Platform, ActivityIndicator, Alert, +} from 'react-native'; +import { router, useLocalSearchParams } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { useMessages } from '@/hooks/useMessages'; +import { encryptMessage } from '@/lib/crypto'; +import { sendEnvelope } from '@/lib/api'; +import { getWSClient } from '@/lib/ws'; +import { appendMessage, loadMessages } from '@/lib/storage'; +import { formatTime, randomId } from '@/lib/utils'; +import { Avatar } from '@/components/ui/Avatar'; +import type { Message } from '@/lib/types'; + +// ─── Design tokens ──────────────────────────────────────────────────────────── +const C = { + bg: '#0b1220', + surface: '#111a2b', + surface2:'#162035', + surface3:'#1a2640', + line: '#1c2840', + text: '#e6edf9', + muted: '#98a7c2', + accent: '#7db5ff', + ok: '#41c98a', + warn: '#f0b35a', + err: '#ff7a87', +} as const; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function shortAddr(a: string, n = 5): string { + if (!a) return '—'; + return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; +} + +/** Group messages by calendar day for day-separator labels. */ +function dateBucket(ts: number): string { + const d = new Date(ts * 1000); + const now = new Date(); + const yday = new Date(); yday.setDate(now.getDate() - 1); + const same = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + if (same(d, now)) return 'Сегодня'; + if (same(d, yday)) return 'Вчера'; + return d.toLocaleDateString('ru', { day: 'numeric', month: 'long' }); +} + +// A row in the FlatList: either a message or a date separator we inject. +type Row = + | { kind: 'msg'; msg: Message } + | { kind: 'sep'; id: string; label: string }; + +function buildRows(msgs: Message[]): Row[] { + const rows: Row[] = []; + let lastBucket = ''; + for (const m of msgs) { + const b = dateBucket(m.timestamp); + if (b !== lastBucket) { + rows.push({ kind: 'sep', id: `sep_${b}_${m.id}`, label: b }); + lastBucket = b; + } + rows.push({ kind: 'msg', msg: m }); + } + return rows; +} + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +export default function ChatScreen() { + const { id: contactAddress } = useLocalSearchParams<{ id: string }>(); + const keyFile = useStore(s => s.keyFile); + const contacts = useStore(s => s.contacts); + const messages = useStore(s => s.messages); + const setMsgs = useStore(s => s.setMessages); + const appendMsg = useStore(s => s.appendMessage); + const insets = useSafeAreaInsets(); + + const contact = contacts.find(c => c.address === contactAddress); + const chatMsgs = messages[contactAddress ?? ''] ?? []; + const listRef = useRef(null); + + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const [peerTyping, setPeerTyping] = useState(false); + + // Poll relay inbox for messages from this contact + useMessages(contact?.x25519Pub ?? ''); + + // Subscribe to typing indicators sent to us. Clears after 3 seconds of + // silence so the "typing…" bubble disappears naturally when the peer + // stops. sendTyping on our side happens per-keystroke (throttled below). + useEffect(() => { + if (!keyFile?.x25519_pub) return; + const ws = getWSClient(); + let clearTimer: ReturnType | null = null; + + const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => { + if (frame.event !== 'typing') return; + const d = frame.data as { from?: string } | undefined; + // Only show typing for the contact currently open in this view. + if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return; + setPeerTyping(true); + if (clearTimer) clearTimeout(clearTimer); + clearTimer = setTimeout(() => setPeerTyping(false), 3_000); + }); + + return () => { + off(); + if (clearTimer) clearTimeout(clearTimer); + }; + }, [keyFile?.x25519_pub, contact?.x25519Pub]); + + // Throttled sendTyping: fire on every keystroke but no more than every 2s. + const lastSentTyping = useRef(0); + const handleTextChange = useCallback((t: string) => { + setText(t); + if (!contact?.x25519Pub || !t.trim()) return; + const now = Date.now(); + if (now - lastSentTyping.current < 2_000) return; + lastSentTyping.current = now; + getWSClient().sendTyping(contact.x25519Pub); + }, [contact?.x25519Pub]); + + // Load cached messages on mount + useEffect(() => { + if (contactAddress) { + loadMessages(contactAddress).then(cached => { + setMsgs(contactAddress, cached as Message[]); + }); + } + }, [contactAddress]); + + const displayName = contact?.username + ? `@${contact.username}` + : contact?.alias ?? shortAddr(contactAddress ?? '', 6); + + const canSend = !!text.trim() && !sending && !!contact?.x25519Pub; + + const send = useCallback(async () => { + if (!text.trim() || !keyFile || !contact) return; + if (!contact.x25519Pub) { + Alert.alert( + 'Ключ ещё не опубликован', + 'Контакт пока не опубликовал ключ шифрования. Попробуйте позже.', + ); + return; + } + setSending(true); + try { + const { nonce, ciphertext } = encryptMessage( + text.trim(), + keyFile.x25519_priv, + contact.x25519Pub, + ); + await sendEnvelope({ + sender_pub: keyFile.x25519_pub, + recipient_pub: contact.x25519Pub, + nonce, + ciphertext, + }); + const msg: Message = { + id: randomId(), + from: keyFile.x25519_pub, + text: text.trim(), + timestamp: Math.floor(Date.now() / 1000), + mine: true, + }; + appendMsg(contact.address, msg); + await appendMessage(contact.address, msg); + setText(''); + setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 50); + } catch (e: any) { + Alert.alert('Ошибка отправки', e.message); + } finally { + setSending(false); + } + }, [text, keyFile, contact]); + + const rows = buildRows(chatMsgs); + + const renderRow = ({ item }: { item: Row }) => { + if (item.kind === 'sep') { + return ( + + + {item.label} + + + ); + } + const m = item.msg; + return ( + + {!m.mine && ( + + + + )} + + + {m.text} + + + + {formatTime(m.timestamp)} + + {m.mine && ( + + )} + + + + ); + }; + + return ( + + {/* ── Header ── */} + + router.back()} + activeOpacity={0.6} + style={{ padding: 8, marginRight: 4 }} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + + + {contact?.x25519Pub && ( + + )} + + + + + {displayName} + + + {peerTyping ? ( + <> + + печатает… + + ) : contact?.x25519Pub ? ( + <> + + E2E шифрование + + ) : ( + <> + + Ожидание ключа шифрования + + )} + + + + + + + + + {/* ── Messages ── */} + r.kind === 'sep' ? r.id : r.msg.id} + renderItem={renderRow} + contentContainerStyle={{ paddingTop: 14, paddingBottom: 10, flexGrow: 1 }} + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} + ListEmptyComponent={() => ( + + + + + + Начните разговор + + + Сообщения зашифрованы end-to-end.{'\n'}Только вы и {displayName} их прочитаете. + + + )} + showsVerticalScrollIndicator={false} + /> + + {/* ── Input bar ── */} + + + + + + ({ + width: 42, height: 42, borderRadius: 21, + backgroundColor: canSend ? C.accent : C.surface2, + alignItems: 'center', justifyContent: 'center', + flexShrink: 0, + opacity: pressed && canSend ? 0.7 : 1, + transform: [{ scale: pressed && canSend ? 0.95 : 1 }], + })} + > + {sending + ? + : + } + + + + ); +} diff --git a/client-app/app/(app)/chats/_layout.tsx b/client-app/app/(app)/chats/_layout.tsx new file mode 100644 index 0000000..9e80355 --- /dev/null +++ b/client-app/app/(app)/chats/_layout.tsx @@ -0,0 +1,7 @@ +import { Stack } from 'expo-router'; + +export default function ChatsLayout() { + return ( + + ); +} diff --git a/client-app/app/(app)/chats/index.tsx b/client-app/app/(app)/chats/index.tsx new file mode 100644 index 0000000..54c74b7 --- /dev/null +++ b/client-app/app/(app)/chats/index.tsx @@ -0,0 +1,337 @@ +/** + * Chat list — DChain messenger. + * Safe-area aware, Ionicons, polished empty states, responsive press feedback. + */ + +import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import { View, Text, FlatList, Pressable, TouchableOpacity } from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { getWSClient } from '@/lib/ws'; +import { Avatar } from '@/components/ui/Avatar'; +import { formatTime, formatAmount } from '@/lib/utils'; +import type { Contact } from '@/lib/types'; + +const MIN_FEE = 5000; + +// ─── Design tokens ──────────────────────────────────────────────────────────── +const C = { + bg: '#0b1220', + surface: '#111a2b', + surface2: '#162035', + surface3: '#1a2640', + line: '#1c2840', + text: '#e6edf9', + muted: '#98a7c2', + accent: '#7db5ff', + ok: '#41c98a', + warn: '#f0b35a', + err: '#ff7a87', +} as const; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Truncate a message preview without breaking words too awkwardly. */ +function previewText(s: string, max = 60): string { + if (s.length <= max) return s; + return s.slice(0, max).trimEnd() + '…'; +} + +/** Short address helper matching the rest of the app. */ +function shortAddr(a: string, n = 6): string { + if (!a) return '—'; + return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}…${a.slice(-n)}`; +} + +export default function ChatsScreen() { + const contacts = useStore(s => s.contacts); + const messages = useStore(s => s.messages); + const requests = useStore(s => s.requests); + const balance = useStore(s => s.balance); + const keyFile = useStore(s => s.keyFile); + const insets = useSafeAreaInsets(); + + // Real-time transport indicator (green dot when WS is live, yellow when + // using HTTP polling fallback). + const [wsLive, setWsLive] = useState(false); + useEffect(() => { + const ws = getWSClient(); + setWsLive(ws.isConnected()); + return ws.onConnectionChange(ok => setWsLive(ok)); + }, []); + + const hasBalance = balance >= MIN_FEE; + + const displayName = (c: Contact) => + c.username ? `@${c.username}` : c.alias ?? shortAddr(c.address, 5); + + const lastMsg = (c: Contact) => { + const msgs = messages[c.address]; + return msgs?.length ? msgs[msgs.length - 1] : null; + }; + + // Sort contacts: most recent activity first. + const sortedContacts = useMemo(() => { + const withTime = contacts.map(c => { + const last = lastMsg(c); + return { + contact: c, + sortKey: last ? last.timestamp : (c.addedAt / 1000), + }; + }); + return withTime + .sort((a, b) => b.sortKey - a.sortKey) + .map(x => x.contact); + }, [contacts, messages]); + + const renderItem = useCallback(({ item: c, index }: { item: Contact; index: number }) => { + const last = lastMsg(c); + const name = displayName(c); + const hasKey = !!c.x25519Pub; + + return ( + router.push(`/(app)/chats/${c.address}`)} + android_ripple={{ color: C.surface2 }} + style={({ pressed }) => ({ + flexDirection: 'row', alignItems: 'center', + paddingHorizontal: 14, paddingVertical: 12, + borderTopWidth: index === 0 ? 0 : 1, borderTopColor: C.line, + backgroundColor: pressed ? C.surface2 : 'transparent', + })} + > + {/* Avatar with E2E status pip */} + + + {hasKey && ( + + + + )} + + + {/* Text column */} + + + + {name} + + {last && ( + + {formatTime(last.timestamp)} + + )} + + + {last?.mine && ( + + )} + + {last ? previewText(last.text) : (hasKey ? 'Напишите первое сообщение' : 'Ожидание публикации ключа…')} + + + + + ); + }, [messages, lastMsg]); + + return ( + + + {/* ── Header ── */} + + + + Сообщения + + {contacts.length > 0 && ( + + + {contacts.length} {contacts.length === 1 ? 'контакт' : contacts.length < 5 ? 'контакта' : 'контактов'} + {' · '} + E2E + {' · '} + + + + {wsLive ? 'live' : 'polling'} + + + )} + + + + {/* Incoming requests chip */} + {requests.length > 0 && ( + router.push('/(app)/requests')} + activeOpacity={0.7} + style={{ + flexDirection: 'row', alignItems: 'center', gap: 6, + backgroundColor: 'rgba(125,181,255,0.14)', borderRadius: 999, + paddingHorizontal: 12, paddingVertical: 7, + borderWidth: 1, borderColor: 'rgba(125,181,255,0.25)', + }} + > + + + {requests.length} + + + )} + + {/* Add contact button */} + hasBalance ? router.push('/(app)/new-contact') : router.push('/(app)/wallet')} + activeOpacity={0.7} + style={{ + width: 38, height: 38, borderRadius: 19, + backgroundColor: hasBalance ? C.accent : C.surface2, + alignItems: 'center', justifyContent: 'center', + }} + > + + + + + + {/* ── No balance gate (no contacts) ── */} + {!hasBalance && contacts.length === 0 && ( + + + + + + + Пополните баланс + + + + Отправка запроса контакта стоит{' '} + {formatAmount(MIN_FEE)} + {' '}— антиспам-сбор идёт напрямую получателю. + + router.push('/(app)/wallet')} + activeOpacity={0.7} + style={{ + flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, + paddingVertical: 11, borderRadius: 10, + backgroundColor: C.accent, + }} + > + + Перейти в кошелёк + + + )} + + {/* ── Low balance warning (has contacts) ── */} + {!hasBalance && contacts.length > 0 && ( + router.push('/(app)/wallet')} + activeOpacity={0.7} + style={{ + flexDirection: 'row', alignItems: 'center', gap: 10, + marginHorizontal: 16, marginTop: 12, + backgroundColor: 'rgba(255,122,135,0.08)', + borderRadius: 10, paddingHorizontal: 12, paddingVertical: 10, + borderWidth: 1, borderColor: 'rgba(255,122,135,0.18)', + }} + > + + + Недостаточно токенов для добавления контакта + + + + )} + + {/* ── Empty state (has balance, no contacts) ── */} + {contacts.length === 0 && hasBalance && ( + + + + + + Нет диалогов + + + Добавьте контакт по адресу или{' '} + @username + {' '}для начала зашифрованной переписки. + + router.push('/(app)/new-contact')} + activeOpacity={0.7} + style={{ + flexDirection: 'row', alignItems: 'center', gap: 8, + paddingHorizontal: 22, paddingVertical: 12, borderRadius: 12, + backgroundColor: C.accent, + }} + > + + Добавить контакт + + + )} + + {/* ── Chat list ── */} + {contacts.length > 0 && ( + c.address} + renderItem={renderItem} + contentContainerStyle={{ paddingBottom: 20 }} + showsVerticalScrollIndicator={false} + /> + )} + + ); +} diff --git a/client-app/app/(app)/new-contact.tsx b/client-app/app/(app)/new-contact.tsx new file mode 100644 index 0000000..1effbe5 --- /dev/null +++ b/client-app/app/(app)/new-contact.tsx @@ -0,0 +1,332 @@ +/** + * Add New Contact — DChain explorer design style. + * Sends CONTACT_REQUEST on-chain with correct amount/fee fields. + */ + +import React, { useState } from 'react'; +import { + View, Text, ScrollView, Alert, TouchableOpacity, TextInput, +} from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { getIdentity, buildContactRequestTx, submitTx } from '@/lib/api'; +import { shortAddr } from '@/lib/crypto'; +import { formatAmount } from '@/lib/utils'; +import { Avatar } from '@/components/ui/Avatar'; + +const C = { + bg: '#0b1220', + surface: '#111a2b', + surface2:'#162035', + line: '#1c2840', + text: '#e6edf9', + muted: '#98a7c2', + accent: '#7db5ff', + ok: '#41c98a', + warn: '#f0b35a', + err: '#ff7a87', +} as const; + +const MIN_CONTACT_FEE = 5000; + +const FEE_OPTIONS = [ + { label: '5 000 µT', value: 5_000, note: 'минимум' }, + { label: '10 000 µT', value: 10_000, note: 'стандарт' }, + { label: '50 000 µT', value: 50_000, note: 'приоритет' }, +]; + +interface Resolved { + address: string; + nickname?: string; + x25519?: string; +} + +export default function NewContactScreen() { + const keyFile = useStore(s => s.keyFile); + const settings = useStore(s => s.settings); + const balance = useStore(s => s.balance); + const insets = useSafeAreaInsets(); + + const [query, setQuery] = useState(''); + const [intro, setIntro] = useState(''); + const [fee, setFee] = useState(MIN_CONTACT_FEE); + const [resolved, setResolved] = useState(null); + const [searching, setSearching] = useState(false); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + + async function search() { + const q = query.trim(); + if (!q) return; + setSearching(true); + setResolved(null); + setError(null); + try { + let address = q; + + // @username lookup via registry contract + if (q.startsWith('@') || (!q.match(/^[0-9a-f]{64}$/i) && !q.startsWith('DC'))) { + const name = q.replace('@', ''); + const { resolveUsername } = await import('@/lib/api'); + const addr = await resolveUsername(settings.contractId, name); + if (!addr) { + setError(`@${name} не зарегистрирован в сети.`); + return; + } + address = addr; + } + + // Fetch identity to get nickname and x25519 key + const identity = await getIdentity(address); + setResolved({ + address: identity?.pub_key ?? address, + nickname: identity?.nickname || undefined, + x25519: identity?.x25519_pub || undefined, + }); + } catch (e: any) { + setError(e.message); + } finally { + setSearching(false); + } + } + + async function sendRequest() { + if (!resolved || !keyFile) return; + if (balance < fee + 1000) { + Alert.alert('Недостаточно средств', `Нужно ${formatAmount(fee + 1000)} (fee + комиссия сети).`); + return; + } + setSending(true); + setError(null); + try { + const tx = buildContactRequestTx({ + from: keyFile.pub_key, + to: resolved.address, + contactFee: fee, + intro: intro.trim() || undefined, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + Alert.alert( + 'Запрос отправлен', + `Контакту ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)} отправлен запрос.`, + [{ text: 'OK', onPress: () => router.back() }], + ); + } catch (e: any) { + setError(e.message); + } finally { + setSending(false); + } + } + + const displayName = resolved + ? (resolved.nickname ? `@${resolved.nickname}` : shortAddr(resolved.address)) + : null; + + return ( + + {/* Header */} + + router.back()} + activeOpacity={0.6} + style={{ padding: 8 }} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + Добавить контакт + + + {/* Search */} + + Адрес или @username + + + + { setQuery(t); setError(null); }} + onSubmitEditing={search} + placeholder="@username или 64-символьный hex" + placeholderTextColor={C.muted} + autoCapitalize="none" + autoCorrect={false} + style={{ color: C.text, fontSize: 14, height: 44 }} + /> + + + + {searching ? '…' : 'Найти'} + + + + + {/* Error */} + {error && ( + + ⚠ {error} + + )} + + {/* Resolved contact card */} + {resolved && ( + + + + {resolved.nickname && ( + + @{resolved.nickname} + + )} + + {resolved.address} + + {resolved.x25519 && ( + + ✓ Зашифрованные сообщения поддерживаются + + )} + + + Найден + + + )} + + {/* Intro + fee (shown after search succeeds) */} + {resolved && ( + <> + {/* Intro */} + + Сообщение (необязательно) + + + + + {intro.length}/280 + + + + {/* Fee selector */} + + Антиспам-сбор (отправляется контакту) + + + {FEE_OPTIONS.map(opt => ( + setFee(opt.value)} + style={{ + flex: 1, borderRadius: 10, paddingVertical: 10, alignItems: 'center', + backgroundColor: fee === opt.value + ? 'rgba(125,181,255,0.15)' : C.surface, + borderWidth: fee === opt.value ? 1 : 0, + borderColor: fee === opt.value ? C.accent : 'transparent', + }} + > + + {opt.label} + + {opt.note} + + ))} + + + {/* Balance info */} + + Ваш баланс: + = fee + 1000 ? C.text : C.err, fontSize: 13, fontWeight: '600' }}> + {formatAmount(balance)} + + + Итого: {formatAmount(fee + 1000)} + + + + {/* Send button */} + + + {sending ? 'Отправка…' : 'Отправить запрос'} + + + + )} + + {/* Hint when no search yet */} + {!resolved && !error && ( + + + Введите @username или вставьте 64-символьный hex-адрес пользователя DChain. + + + )} + + ); +} diff --git a/client-app/app/(app)/requests.tsx b/client-app/app/(app)/requests.tsx new file mode 100644 index 0000000..75e76bc --- /dev/null +++ b/client-app/app/(app)/requests.tsx @@ -0,0 +1,236 @@ +/** + * Contact requests screen — DChain explorer design style. + */ + +import React, { useState } from 'react'; +import { View, Text, FlatList, Alert, TouchableOpacity } from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { buildAcceptContactTx, submitTx, getIdentity } from '@/lib/api'; +import { saveContact } from '@/lib/storage'; +import { shortAddr } from '@/lib/crypto'; +import { relativeTime } from '@/lib/utils'; +import { Avatar } from '@/components/ui/Avatar'; +import type { ContactRequest } from '@/lib/types'; + +const C = { + bg: '#0b1220', + surface: '#111a2b', + surface2:'#162035', + line: '#1c2840', + text: '#e6edf9', + muted: '#98a7c2', + accent: '#7db5ff', + ok: '#41c98a', + warn: '#f0b35a', + err: '#ff7a87', +} as const; + +export default function RequestsScreen() { + const keyFile = useStore(s => s.keyFile); + const requests = useStore(s => s.requests); + const setRequests = useStore(s => s.setRequests); + const upsertContact = useStore(s => s.upsertContact); + const insets = useSafeAreaInsets(); + + const [accepting, setAccepting] = useState(null); + + async function accept(req: ContactRequest) { + if (!keyFile) return; + setAccepting(req.txHash); + try { + // Fetch requester's identity to get their x25519 key for messaging + const identity = await getIdentity(req.from); + const x25519Pub = identity?.x25519_pub ?? ''; + + const tx = buildAcceptContactTx({ + from: keyFile.pub_key, + to: req.from, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + + // Save contact with x25519 key (empty if they haven't registered one) + const contact = { + address: req.from, + x25519Pub, + username: req.username, + addedAt: Date.now(), + }; + upsertContact(contact); + await saveContact(contact); + + setRequests(requests.filter(r => r.txHash !== req.txHash)); + Alert.alert('Принято', `${req.username ? '@' + req.username : shortAddr(req.from)} добавлен в контакты.`); + } catch (e: any) { + Alert.alert('Ошибка', e.message); + } finally { + setAccepting(null); + } + } + + function decline(req: ContactRequest) { + Alert.alert( + 'Отклонить запрос', + `Отклонить запрос от ${req.username ? '@' + req.username : shortAddr(req.from)}?`, + [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Отклонить', + style: 'destructive', + onPress: () => setRequests(requests.filter(r => r.txHash !== req.txHash)), + }, + ], + ); + } + + return ( + + {/* Header */} + + router.back()} + activeOpacity={0.6} + style={{ padding: 8 }} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + Запросы контактов + + {requests.length > 0 && ( + + + {requests.length} + + + )} + + + {requests.length === 0 ? ( + + + + + + Нет входящих запросов + + + Когда кто-то пришлёт вам запрос в контакты, он появится здесь. + + + ) : ( + r.txHash} + contentContainerStyle={{ padding: 14, paddingBottom: 24, gap: 12 }} + renderItem={({ item: req }) => ( + accept(req)} + onDecline={() => decline(req)} + /> + )} + /> + )} + + ); +} + +function RequestCard({ + req, isAccepting, onAccept, onDecline, +}: { + req: ContactRequest; + isAccepting: boolean; + onAccept: () => void; + onDecline: () => void; +}) { + const displayName = req.username ? `@${req.username}` : shortAddr(req.from); + + return ( + + {/* Sender info */} + + + + + {displayName} + + + {req.from} + + + {relativeTime(req.timestamp)} + + + {/* Intro message */} + {!!req.intro && ( + + Приветствие + {req.intro} + + )} + + {/* Divider */} + + + {/* Actions */} + + + + + {isAccepting ? 'Принятие…' : 'Принять'} + + + + + Отклонить + + + + ); +} diff --git a/client-app/app/(app)/settings.tsx b/client-app/app/(app)/settings.tsx new file mode 100644 index 0000000..6c7fd52 --- /dev/null +++ b/client-app/app/(app)/settings.tsx @@ -0,0 +1,732 @@ +/** + * Settings screen — DChain explorer style, inline styles, Russian locale. + */ + +import React, { useState, useEffect } from 'react'; +import { + View, Text, ScrollView, TextInput, Alert, TouchableOpacity, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { saveSettings, deleteKeyFile } from '@/lib/storage'; +import { + setNodeUrl, getNetStats, resolveUsername, reverseResolve, + buildCallContractTx, submitTx, + USERNAME_REGISTRATION_FEE, MIN_USERNAME_LENGTH, MAX_USERNAME_LENGTH, + humanizeTxError, +} from '@/lib/api'; +import { shortAddr } from '@/lib/crypto'; +import { formatAmount } from '@/lib/utils'; +import { Avatar } from '@/components/ui/Avatar'; + +// ─── Design tokens ──────────────────────────────────────────────────────────── +const C = { + bg: '#0b1220', + surface: '#111a2b', + surface2:'#162035', + surface3:'#1a2640', + line: '#1c2840', + text: '#e6edf9', + muted: '#98a7c2', + accent: '#7db5ff', + ok: '#41c98a', + warn: '#f0b35a', + err: '#ff7a87', +} as const; + +// ─── Reusable sub-components ───────────────────────────────────────────────── + +function SectionLabel({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function Card({ children, style }: { children: React.ReactNode; style?: object }) { + return ( + + {children} + + ); +} + +function CardRow({ + icon, label, value, first, +}: { + icon: keyof typeof Ionicons.glyphMap; + label: string; + value?: string; + first?: boolean; +}) { + return ( + + + + + + {label} + {value !== undefined && ( + + {value} + + )} + + + ); +} + +function FieldInput({ + label, value, onChangeText, placeholder, keyboardType, autoCapitalize, autoCorrect, +}: { + label: string; + value: string; + onChangeText: (v: string) => void; + placeholder?: string; + keyboardType?: any; + autoCapitalize?: any; + autoCorrect?: boolean; +}) { + return ( + + {label} + + + + + ); +} + +// ─── Screen ─────────────────────────────────────────────────────────────────── + +export default function SettingsScreen() { + const keyFile = useStore(s => s.keyFile); + const settings = useStore(s => s.settings); + const setSettings = useStore(s => s.setSettings); + const username = useStore(s => s.username); + const setUsername = useStore(s => s.setUsername); + const setKeyFile = useStore(s => s.setKeyFile); + const balance = useStore(s => s.balance); + + const [nodeUrl, setNodeUrlLocal] = useState(settings.nodeUrl); + const [contractId, setContractId] = useState(settings.contractId); + const [nodeStatus, setNodeStatus] = useState<'checking' | 'ok' | 'error'>('checking'); + const [peerCount, setPeerCount] = useState(null); + const [blockCount, setBlockCount] = useState(null); + const [copied, setCopied] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const insets = useSafeAreaInsets(); + + // Username registration state + const [nameInput, setNameInput] = useState(''); + const [registering, setRegistering] = useState(false); + const [nameError, setNameError] = useState(null); + + // Sanitize: lowercase, only a-z 0-9 _ -, max 32 (contract limit) + function onNameInputChange(v: string) { + const cleaned = v.toLowerCase().replace(/[^a-z0-9_\-]/g, '').slice(0, MAX_USERNAME_LENGTH); + setNameInput(cleaned); + setNameError(null); + } + + async function registerUsername() { + if (!keyFile) return; + const name = nameInput.trim(); + // Mirror blockchain/native_username.go validateName so the UI gives + // immediate feedback without a round trip to the chain. + if (name.length < MIN_USERNAME_LENGTH) { + setNameError(`Минимум ${MIN_USERNAME_LENGTH} символа`); + return; + } + if (!/^[a-z]/.test(name)) { + setNameError('Имя должно начинаться с буквы a-z'); + return; + } + if (!settings.contractId) { + setNameError('Не задан ID контракта реестра в настройках ноды.'); + return; + } + const fee = USERNAME_REGISTRATION_FEE; + // Reserve for: registry fee (burned) + MIN_CALL_FEE (to validator) + // + small gas headroom (native contract is cheap, but gas is pre-charged). + const GAS_HEADROOM = 2_000; + const total = fee + 1000 + GAS_HEADROOM; + if (balance < total) { + setNameError(`Нужно ${formatAmount(total)} (с запасом на газ), доступно ${formatAmount(balance)}.`); + return; + } + // Check if name is already taken + try { + const existing = await resolveUsername(settings.contractId, name); + if (existing) { + setNameError(`@${name} уже зарегистрировано.`); + return; + } + } catch { + // ignore — lookup failure is OK, contract will reject on duplicate + } + + Alert.alert( + 'Купить @' + name + '?', + `Стоимость: ${formatAmount(fee)} + комиссия ${formatAmount(1000)}.\nИмя привязывается к вашему адресу навсегда (до release).`, + [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Купить', onPress: async () => { + setRegistering(true); + setNameError(null); + try { + const tx = buildCallContractTx({ + from: keyFile.pub_key, + contractId: settings.contractId, + method: 'register', + args: [name], + // Attach the registration fee in tx.amount — the contract + // requires exactly this much and burns it. Visible in the + // explorer so the user sees the real cost. + amount: USERNAME_REGISTRATION_FEE, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + Alert.alert( + 'Отправлено', + `Транзакция покупки @${name} принята. Имя появится в профиле через несколько секунд.`, + ); + setNameInput(''); + // Poll every 2s for up to 20s until the address ↔ name binding is visible. + let attempts = 0; + const iv = setInterval(async () => { + attempts++; + const got = keyFile + ? await reverseResolve(settings.contractId, keyFile.pub_key) + : null; + if (got) { + setUsername(got); + clearInterval(iv); + } else if (attempts >= 10) { + clearInterval(iv); + } + }, 2000); + } catch (e: any) { + setNameError(humanizeTxError(e)); + } finally { + setRegistering(false); + } + }, + }, + ], + ); + } + + // Flat fee — same for every name that passes validation. + const nameFee = USERNAME_REGISTRATION_FEE; + const nameIsValid = nameInput.length >= MIN_USERNAME_LENGTH && /^[a-z]/.test(nameInput); + + useEffect(() => { checkNode(); }, []); + + // Pick up auto-discovered contract IDs (useWellKnownContracts updates the + // store; reflect it into the local TextInput state so the UI stays consistent). + useEffect(() => { + setContractId(settings.contractId); + }, [settings.contractId]); + + // When the registry contract becomes known (either via manual save or + // auto-discovery), look up the user's registered username reactively. + // Sets username unconditionally — a null result CLEARS the cached name, + // which matters when the user switches nodes / chains: a name on the + // previous chain should no longer show when connected to a chain where + // the same pubkey isn't registered. + useEffect(() => { + if (!settings.contractId || !keyFile) { + setUsername(null); + return; + } + (async () => { + const name = await reverseResolve(settings.contractId, keyFile.pub_key); + setUsername(name); + })(); + }, [settings.contractId, keyFile, setUsername]); + + async function checkNode() { + setNodeStatus('checking'); + try { + const stats = await getNetStats(); + setNodeStatus('ok'); + setPeerCount(stats.peer_count); + setBlockCount(stats.total_blocks); + if (settings.contractId && keyFile) { + // Address → username: must use reverseResolve, not resolveUsername + // (resolveUsername goes username → address). + const name = await reverseResolve(settings.contractId, keyFile.pub_key); + if (name) setUsername(name); + } + } catch { + setNodeStatus('error'); + } + } + + async function saveNode() { + const url = nodeUrl.trim().replace(/\/$/, ''); + setNodeUrl(url); + const next = { nodeUrl: url, contractId: contractId.trim() }; + setSettings(next); + await saveSettings(next); + Alert.alert('Сохранено', 'Настройки ноды обновлены.'); + checkNode(); + } + + async function exportKey() { + if (!keyFile) return; + try { + const json = JSON.stringify(keyFile, null, 2); + const path = FileSystem.cacheDirectory + 'dchain_key.json'; + await FileSystem.writeAsStringAsync(path, json); + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(path, { + mimeType: 'application/json', + dialogTitle: 'Экспорт ключа DChain', + }); + } + } catch (e: any) { + Alert.alert('Ошибка экспорта', e.message); + } + } + + async function copyAddress() { + if (!keyFile) return; + await Clipboard.setStringAsync(keyFile.pub_key); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + function logout() { + Alert.alert( + 'Удалить аккаунт', + 'Ключ будет удалён с устройства. Убедитесь, что у вас есть резервная копия!', + [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Удалить', + style: 'destructive', + onPress: async () => { + await deleteKeyFile(); + setKeyFile(null); + router.replace('/'); + }, + }, + ], + ); + } + + const statusColor = nodeStatus === 'ok' ? C.ok : nodeStatus === 'error' ? C.err : C.warn; + const statusLabel = nodeStatus === 'ok' ? 'Подключена' : nodeStatus === 'error' ? 'Недоступна' : 'Проверка…'; + + return ( + + + Настройки + + + {/* ── Профиль ── */} + Профиль + + {/* Avatar row */} + + + + {username ? ( + @{username} + ) : ( + Имя не зарегистрировано + )} + + {keyFile ? shortAddr(keyFile.pub_key, 10) : '—'} + + + + + {/* Copy address */} + + + + + {copied ? 'Скопировано' : 'Скопировать адрес'} + + + + + + {/* ── Имя пользователя ── */} + Имя пользователя + + {username ? ( + // Already registered + + + + + + @{username} + Привязано к вашему адресу + + + Активно + + + ) : ( + // Register new + <> + + + Купить никнейм + + + Короткие имена дороже. Оплата идёт в казну контракта реестра. + + + + {/* Input */} + + + @ + + {nameInput.length > 0 && ( + {nameInput.length} + )} + + {nameError && ( + ⚠ {nameError} + )} + + + {/* Fee breakdown + rules */} + + + {/* Primary cost line */} + + + Плата за ник (сгорает) + + {formatAmount(nameFee)} + + + + + Комиссия сети (валидатору) + + {formatAmount(1000)} + + + + {/* Total */} + + Итого + + {formatAmount(nameFee + 1000)} + + + + {/* Rules */} + + Минимум {MIN_USERNAME_LENGTH} символа, только{' '} + a-z 0-9 _ - + , первый символ — буква. + + + + + {/* Register button */} + + + + + {registering ? 'Покупка…' : 'Купить никнейм'} + + + {!settings.contractId && ( + + Укажите ID контракта реестра в настройках ноды ниже + + )} + + + )} + + + {/* ── Нода ── */} + Нода + + {/* Connection status */} + + + + + + Подключение + {statusLabel} + + {nodeStatus === 'ok' && ( + + {peerCount !== null && ( + + {peerCount} пиров + + )} + {blockCount !== null && ( + + {blockCount.toLocaleString()} блоков + + )} + + )} + + + {/* Node URL input */} + + + + + {/* Registry contract — auto-detected from node; manual override under advanced */} + + + + + КОНТРАКТ РЕЕСТРА ИМЁН + + + {settings.contractId ? 'Авто-обнаружен' : 'Не найден'} + + + + {settings.contractId || '—'} + + setShowAdvanced(v => !v)} + style={{ marginTop: 8 }} + > + + {showAdvanced ? '▾ Скрыть ручной ввод' : '▸ Указать вручную (не требуется)'} + + + {showAdvanced && ( + + + + Оставьте пустым — клиент запросит канонический контракт у ноды. + + + )} + + + {/* Save button */} + + + + Сохранить и переподключиться + + + + + {/* ── Безопасность ── */} + Безопасность + + + + + + + Экспорт ключа + Сохранить приватный ключ как key.json + + + Экспорт + + + + + {/* ── Опасная зона ── */} + Опасная зона + + + + + Удалить аккаунт + + + Удаляет ключ с устройства. Онлайн-идентичность сохраняется, но доступ будет потерян без резервной копии. + + + + Удалить с устройства + + + + + ); +} diff --git a/client-app/app/(app)/wallet.tsx b/client-app/app/(app)/wallet.tsx new file mode 100644 index 0000000..829869e --- /dev/null +++ b/client-app/app/(app)/wallet.tsx @@ -0,0 +1,596 @@ +/** + * Wallet screen — DChain explorer style. + * Balance block inspired by Tinkoff/Gravity UI reference. + * Icons: Ionicons from @expo/vector-icons. + */ + +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, Text, ScrollView, Modal, TouchableOpacity, + Alert, RefreshControl, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useStore } from '@/lib/store'; +import { useBalance } from '@/hooks/useBalance'; +import { buildTransferTx, submitTx, getTxHistory, getBalance } from '@/lib/api'; +import { shortAddr } from '@/lib/crypto'; +import { formatAmount, relativeTime } from '@/lib/utils'; +import { Input } from '@/components/ui/Input'; +import type { TxRecord } from '@/lib/types'; + +// ─── Design tokens ──────────────────────────────────────────────────────────── +const C = { + bg: '#0b1220', + surface: '#111a2b', + surface2:'#162035', + surface3:'#1a2640', + line: '#1c2840', + text: '#e6edf9', + muted: '#98a7c2', + accent: '#7db5ff', + ok: '#41c98a', + warn: '#f0b35a', + err: '#ff7a87', +} as const; + +// ─── TX metadata ────────────────────────────────────────────────────────────── + +const TX_META: Record = { + TRANSFER: { label: 'Перевод', icon: 'paper-plane-outline', color: C.accent }, + CONTACT_REQUEST: { label: 'Запрос контакта', icon: 'person-add-outline', color: C.ok }, + ACCEPT_CONTACT: { label: 'Принят контакт', icon: 'person-outline', color: C.ok }, + BLOCK_CONTACT: { label: 'Блокировка', icon: 'ban-outline', color: C.err }, + DEPLOY_CONTRACT: { label: 'Деплой контракта',icon: 'document-text-outline', color: C.warn }, + CALL_CONTRACT: { label: 'Вызов контракта', icon: 'flash-outline', color: C.warn }, + STAKE: { label: 'Стейкинг', icon: 'lock-closed-outline', color: C.accent}, + UNSTAKE: { label: 'Вывод стейка', icon: 'lock-open-outline', color: C.muted }, + REGISTER_KEY: { label: 'Регистрация', icon: 'key-outline', color: C.muted }, + BLOCK_REWARD: { label: 'Награда', icon: 'diamond-outline', color: C.ok }, +}; + +function txMeta(type: string) { + return TX_META[type] ?? { label: type.replace(/_/g, ' '), icon: 'ellipse-outline' as any, color: C.muted }; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function WalletScreen() { + const keyFile = useStore(s => s.keyFile); + const balance = useStore(s => s.balance); + const setBalance = useStore(s => s.setBalance); + const insets = useSafeAreaInsets(); + + useBalance(); + + const [txHistory, setTxHistory] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [showSend, setShowSend] = useState(false); + const [selectedTx, setSelectedTx] = useState(null); + const [toAddress, setToAddress] = useState(''); + const [amount, setAmount] = useState(''); + const [fee, setFee] = useState('1000'); + const [sending, setSending] = useState(false); + const [copied, setCopied] = useState(false); + + const load = useCallback(async () => { + if (!keyFile) return; + setRefreshing(true); + try { + const [hist, bal] = await Promise.all([ + getTxHistory(keyFile.pub_key), + getBalance(keyFile.pub_key), + ]); + setTxHistory(hist); + setBalance(bal); + } catch {} + setRefreshing(false); + }, [keyFile]); + + useEffect(() => { load(); }, [load]); + + const copyAddress = async () => { + if (!keyFile) return; + await Clipboard.setStringAsync(keyFile.pub_key); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const send = async () => { + if (!keyFile) return; + const amt = parseInt(amount); + const f = parseInt(fee); + if (!toAddress.trim() || isNaN(amt) || amt <= 0) { + Alert.alert('Неверные данные', 'Введите корректный адрес и сумму.'); + return; + } + if (amt + f > balance) { + Alert.alert('Недостаточно средств', `Нужно ${formatAmount(amt + f)}, доступно ${formatAmount(balance)}.`); + return; + } + setSending(true); + try { + const tx = buildTransferTx({ from: keyFile.pub_key, to: toAddress.trim(), amount: amt, fee: f, privKey: keyFile.priv_key }); + await submitTx(tx); + setShowSend(false); + setToAddress(''); + setAmount(''); + Alert.alert('Отправлено', 'Транзакция принята нодой.'); + setTimeout(load, 1500); + } catch (e: any) { + Alert.alert('Ошибка', e.message); + } finally { + setSending(false); + } + }; + + return ( + + } + contentContainerStyle={{ paddingBottom: 32 }} + > + {/* ── Balance hero ── */} + setShowSend(true)} + onReceive={copyAddress} + onRefresh={load} + onCopy={copyAddress} + /> + + {/* ── Transaction list ── */} + + + История транзакций + + + {txHistory.length === 0 ? ( + + + Нет транзакций + Потяните вниз, чтобы обновить + + ) : ( + + {/* Table header */} + + + ТИП + + + АДРЕС + + + СУММА + + + + {txHistory.map((tx, i) => ( + setSelectedTx(tx)} + /> + ))} + + )} + + + + {/* Send modal */} + setShowSend(false)}> + setShowSend(false)} + /> + + + {/* TX Detail modal */} + setSelectedTx(null)}> + {selectedTx && ( + setSelectedTx(null)} /> + )} + + + ); +} + +// ─── Balance Hero ───────────────────────────────────────────────────────────── + +function BalanceHero({ + balance, address, copied, topInset, onSend, onReceive, onRefresh, onCopy, +}: { + balance: number; address: string; copied: boolean; + topInset: number; + onSend: () => void; onReceive: () => void; + onRefresh: () => void; onCopy: () => void; +}) { + return ( + + {/* Label */} + + Баланс + + + {/* Main balance */} + + {formatAmount(balance)} + + + {/* µT sub-label */} + + {(balance ?? 0).toLocaleString()} µT + + + {/* Address chip */} + + + + {copied ? 'Скопировано' : (address ? shortAddr(address, 8) : '—')} + + + + {/* Action buttons */} + + + + + + + ); +} + +function ActionButton({ + icon, label, color, onPress, +}: { + icon: keyof typeof Ionicons.glyphMap; label: string; color: string; onPress: () => void; +}) { + return ( + + + + + {label} + + ); +} + +// ─── Transaction Row ────────────────────────────────────────────────────────── +// Column widths must match the header above: +// col1 (type+icon): 140 col2 (address): flex:1 col3 (amount): 84 + +// tx types where "from" is always the owner but it's income, not a send +const RECEIVED_TYPES = new Set(['BLOCK_REWARD', 'STAKE_REWARD']); + +function TxRow({ + tx, myPubKey, isLast, onPress, +}: { + tx: TxRecord; myPubKey: string; isLast: boolean; onPress: () => void; +}) { + const meta = txMeta(tx.type); + const isSynthetic = !tx.from; // block reward / mint + const isSent = !isSynthetic && !RECEIVED_TYPES.has(tx.type) && tx.from === myPubKey; + const amt = tx.amount ?? 0; + const amtText = amt === 0 ? '' : `${isSent ? '−' : '+'}${formatAmount(amt)}`; + const amtColor = isSent ? C.err : C.ok; + + // Counterpart label: for synthetic (empty from) rewards → "Сеть", + // otherwise show the short address of the other side. + const counterpart = isSynthetic + ? 'Сеть' + : isSent + ? (tx.to ? shortAddr(tx.to, 6) : '—') + : shortAddr(tx.from, 6); + + return ( + + {/* Col 1 — icon + type label, fixed 140px */} + + + + + + {meta.label} + + + + {/* Col 2 — address, flex */} + + + {isSent ? `→ ${counterpart}` : `← ${counterpart}`} + + + + {/* Col 3 — amount + time, fixed 84px, right-aligned */} + + {!!amtText && ( + + {amtText} + + )} + + {relativeTime(tx.timestamp)} + + + + ); +} + +// ─── Send Sheet ─────────────────────────────────────────────────────────────── + +function SendSheet({ + balance, toAddress, setToAddress, amount, setAmount, fee, setFee, sending, onSend, onClose, +}: { + balance: number; + toAddress: string; setToAddress: (v: string) => void; + amount: string; setAmount: (v: string) => void; + fee: string; setFee: (v: string) => void; + sending: boolean; onSend: () => void; onClose: () => void; +}) { + return ( + + + Отправить + + + + + + + + Доступно: + {formatAmount(balance)} + + + + + + + + {!sending && } + + {sending ? 'Отправка…' : 'Подтвердить'} + + + + ); +} + +// ─── TX Detail Sheet ────────────────────────────────────────────────────────── + +function TxDetailSheet({ + tx, myPubKey, onClose, +}: { + tx: TxRecord; myPubKey: string; onClose: () => void; +}) { + const [copiedHash, setCopiedHash] = useState(false); + const meta = txMeta(tx.type); + const isSent = tx.from === myPubKey; + const amtValue = tx.amount ?? 0; + + const copyHash = async () => { + await Clipboard.setStringAsync(tx.hash); + setCopiedHash(true); + setTimeout(() => setCopiedHash(false), 2000); + }; + + const amtColor = amtValue === 0 ? C.muted : isSent ? C.err : C.ok; + const amtSign = amtValue === 0 ? '' : isSent ? '−' : '+'; + + return ( + + {/* Handle */} + + + + + + {/* Header */} + + Транзакция + + + + + + {/* Hero */} + + + + + 0 ? 8 : 0 }}> + {meta.label} + + {amtValue > 0 && ( + + {amtSign}{formatAmount(amtValue)} + + )} + + + {tx.status === 'confirmed' ? '✓ Подтверждена' : '⏳ В обработке'} + + + + + {/* Details table */} + + + {tx.amount !== undefined && ( + + )} + + {tx.timestamp > 0 && ( + + )} + + + {/* Addresses */} + + {/* "From" for synthetic txs (empty tx.from) reads "Сеть" rather than an empty row. */} + + {tx.to && ( + + )} + + + {/* TX Hash */} + + + + + + TX ID / Hash + + + + {tx.hash} + + + + + + + + {copiedHash ? 'Скопировано' : 'Копировать хеш'} + + + + + ); +} + +// ─── Detail Row ─────────────────────────────────────────────────────────────── + +function DetailRow({ + icon, label, value, mono, truncate, first, +}: { + icon: keyof typeof Ionicons.glyphMap; + label: string; value: string; + mono?: boolean; truncate?: boolean; first?: boolean; +}) { + return ( + + + {label} + + {value} + + + ); +} diff --git a/client-app/app/(auth)/create.tsx b/client-app/app/(auth)/create.tsx new file mode 100644 index 0000000..b21712c --- /dev/null +++ b/client-app/app/(auth)/create.tsx @@ -0,0 +1,82 @@ +/** + * Create Account screen. + * Generates a new Ed25519 + X25519 keypair and saves it securely. + */ + +import React, { useState } from 'react'; +import { View, Text, ScrollView, Alert } from 'react-native'; +import { router } from 'expo-router'; +import { generateKeyFile } from '@/lib/crypto'; +import { saveKeyFile } from '@/lib/storage'; +import { useStore } from '@/lib/store'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Card } from '@/components/ui/Card'; + +export default function CreateAccountScreen() { + const setKeyFile = useStore(s => s.setKeyFile); + const [loading, setLoading] = useState(false); + + async function handleCreate() { + setLoading(true); + try { + const kf = generateKeyFile(); + await saveKeyFile(kf); + setKeyFile(kf); + router.replace('/(auth)/created'); + } catch (e: any) { + Alert.alert('Error', e.message); + } finally { + setLoading(false); + } + } + + return ( + + {/* Header */} + + + Create Account + + A new identity will be generated on your device. + Your private key never leaves this app. + + + {/* Info cards */} + + + + + + + + ⚠ Important + + After creation, export and backup your key file. + If you lose it there is no recovery — the blockchain has no password reset. + + + + + + ); +} + +function InfoRow({ icon, label, desc }: { icon: string; label: string; desc: string }) { + return ( + + {icon} + + {label} + {desc} + + + ); +} diff --git a/client-app/app/(auth)/created.tsx b/client-app/app/(auth)/created.tsx new file mode 100644 index 0000000..241d63c --- /dev/null +++ b/client-app/app/(auth)/created.tsx @@ -0,0 +1,118 @@ +/** + * Account Created confirmation screen. + * Shows address, pubkeys, and export options. + */ + +import React, { useState } from 'react'; +import { View, Text, ScrollView, Alert, Share } from 'react-native'; +import { router } from 'expo-router'; +import * as Clipboard from 'expo-clipboard'; +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import { useStore } from '@/lib/store'; +import { shortAddr } from '@/lib/crypto'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { Separator } from '@/components/ui/Separator'; + +export default function AccountCreatedScreen() { + const keyFile = useStore(s => s.keyFile); + const [copied, setCopied] = useState(null); + + if (!keyFile) { + router.replace('/'); + return null; + } + + async function copy(value: string, label: string) { + await Clipboard.setStringAsync(value); + setCopied(label); + setTimeout(() => setCopied(null), 2000); + } + + async function exportKey() { + try { + const json = JSON.stringify(keyFile, null, 2); + const path = FileSystem.cacheDirectory + 'dchain_key.json'; + await FileSystem.writeAsStringAsync(path, json); + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(path, { + mimeType: 'application/json', + dialogTitle: 'Save your DChain key file', + }); + } else { + Alert.alert('Export', 'Sharing not available on this device.'); + } + } catch (e: any) { + Alert.alert('Export failed', e.message); + } + } + + return ( + + {/* Success header */} + + + + + Account Created! + + Your keys have been generated and stored securely. + + + + {/* Address card */} + + + Your Address (Ed25519) + + + {keyFile.pub_key} + + + + + {/* X25519 key */} + + + Encryption Key (X25519) + + + {keyFile.x25519_pub} + + + + + {/* Export warning */} + + 🔐 Backup your key file + + Export dchain_key.json and store it safely. + This file contains your private keys — keep it secret. + + + + + + + ); +} diff --git a/client-app/app/(auth)/import.tsx b/client-app/app/(auth)/import.tsx new file mode 100644 index 0000000..ca814f1 --- /dev/null +++ b/client-app/app/(auth)/import.tsx @@ -0,0 +1,301 @@ +/** + * Import Existing Key screen. + * Two methods: + * 1. Paste JSON directly into a text field + * 2. Pick key.json file via document picker + */ + +import React, { useState } from 'react'; +import { + View, Text, ScrollView, TextInput, + TouchableOpacity, Alert, Pressable, +} from 'react-native'; +import { router } from 'expo-router'; +import * as DocumentPicker from 'expo-document-picker'; +import * as Clipboard from 'expo-clipboard'; +import { saveKeyFile } from '@/lib/storage'; +import { useStore } from '@/lib/store'; +import { Button } from '@/components/ui/Button'; +import type { KeyFile } from '@/lib/types'; + +type Tab = 'paste' | 'file'; + +const REQUIRED_FIELDS: (keyof KeyFile)[] = ['pub_key', 'priv_key', 'x25519_pub', 'x25519_priv']; + +function validateKeyFile(raw: string): KeyFile { + let parsed: any; + try { + parsed = JSON.parse(raw.trim()); + } catch { + throw new Error('Invalid JSON — check that you copied the full key file contents.'); + } + for (const field of REQUIRED_FIELDS) { + if (!parsed[field] || typeof parsed[field] !== 'string') { + throw new Error(`Missing or invalid field: "${field}"`); + } + if (!/^[0-9a-f]+$/i.test(parsed[field])) { + throw new Error(`Field "${field}" must be a hex string.`); + } + } + return parsed as KeyFile; +} + +export default function ImportKeyScreen() { + const setKeyFile = useStore(s => s.setKeyFile); + + const [tab, setTab] = useState('paste'); + const [jsonText, setJsonText] = useState(''); + const [fileName, setFileName] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // ── Shared: save validated key and navigate ────────────────────────────── + async function applyKey(kf: KeyFile) { + setLoading(true); + setError(null); + try { + await saveKeyFile(kf); + setKeyFile(kf); + router.replace('/(app)/chats'); + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + } + + // ── Method 1: paste JSON ───────────────────────────────────────────────── + async function handlePasteImport() { + setError(null); + const text = jsonText.trim(); + if (!text) { + // Try reading clipboard if field is empty + const clip = await Clipboard.getStringAsync(); + if (clip) setJsonText(clip); + return; + } + try { + const kf = validateKeyFile(text); + await applyKey(kf); + } catch (e: any) { + setError(e.message); + } + } + + // ── Method 2: pick file ────────────────────────────────────────────────── + async function pickFile() { + setError(null); + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ['application/json', 'text/plain', '*/*'], + copyToCacheDirectory: true, + }); + if (result.canceled) return; + + const asset = result.assets[0]; + setFileName(asset.name); + + // Use fetch() — readAsStringAsync is deprecated in newer expo-file-system + const response = await fetch(asset.uri); + const raw = await response.text(); + + const kf = validateKeyFile(raw); + await applyKey(kf); + } catch (e: any) { + setError(e.message); + } + } + + const tabStyle = (t: Tab) => ({ + flex: 1 as const, + paddingVertical: 10, + alignItems: 'center' as const, + borderBottomWidth: 2, + borderBottomColor: tab === t ? '#2563eb' : 'transparent', + }); + + const tabTextStyle = (t: Tab) => ({ + fontSize: 14, + fontWeight: '600' as const, + color: tab === t ? '#fff' : '#8b949e', + }); + + return ( + + {/* Back */} + router.back()} style={{ marginBottom: 24, alignSelf: 'flex-start' }}> + ← Back + + + + Import Key + + + Restore your account from an existing{' '} + key.json. + + + {/* Tabs */} + + setTab('paste')}> + 📋 Paste JSON + + setTab('file')}> + 📁 Open File + + + + {/* ── Paste tab ── */} + {tab === 'paste' && ( + + + Key JSON + + + + { setJsonText(t); setError(null); }} + placeholder={'{\n "pub_key": "...",\n "priv_key": "...",\n "x25519_pub": "...",\n "x25519_priv": "..."\n}'} + placeholderTextColor="#8b949e" + multiline + numberOfLines={8} + autoCapitalize="none" + autoCorrect={false} + style={{ + color: '#fff', + fontFamily: 'monospace', + fontSize: 12, + lineHeight: 18, + minHeight: 160, + textAlignVertical: 'top', + }} + /> + + + {/* Paste from clipboard shortcut */} + {!jsonText && ( + { + const clip = await Clipboard.getStringAsync(); + if (clip) { setJsonText(clip); setError(null); } + else Alert.alert('Clipboard empty', 'Copy your key JSON first.'); + }} + style={{ + flexDirection: 'row', alignItems: 'center', gap: 8, + padding: 12, backgroundColor: '#161b22', + borderWidth: 1, borderColor: '#30363d', borderRadius: 12, + }} + > + 📋 + Paste from clipboard + + )} + + {error && ( + + ⚠ {error} + + )} + + + + )} + + {/* ── File tab ── */} + {tab === 'file' && ( + + + 📂 + + {fileName ?? 'Choose key.json'} + + + Tap to browse files + + + + {fileName && ( + + 📄 + + {fileName} + + + )} + + {error && ( + + ⚠ {error} + + )} + + {loading && ( + + Validating key… + + )} + + )} + + {/* Format hint */} + + + Expected format + + + {`{\n "pub_key": "<64 hex chars>",\n "priv_key": "<128 hex chars>",\n "x25519_pub": "<64 hex chars>",\n "x25519_priv": "<64 hex chars>"\n}`} + + + + ); +} diff --git a/client-app/app/_layout.tsx b/client-app/app/_layout.tsx new file mode 100644 index 0000000..8b70cc4 --- /dev/null +++ b/client-app/app/_layout.tsx @@ -0,0 +1,40 @@ +import '../global.css'; + +import React, { useEffect } from 'react'; +import { Stack } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import { View } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { loadKeyFile, loadSettings } from '@/lib/storage'; +import { setNodeUrl } from '@/lib/api'; +import { useStore } from '@/lib/store'; + +export default function RootLayout() { + const setKeyFile = useStore(s => s.setKeyFile); + const setSettings = useStore(s => s.setSettings); + + // Bootstrap: load key + settings from storage + useEffect(() => { + (async () => { + const [kf, settings] = await Promise.all([loadKeyFile(), loadSettings()]); + if (kf) setKeyFile(kf); + setSettings(settings); + setNodeUrl(settings.nodeUrl); + })(); + }, []); + + return ( + + + + + + + ); +} diff --git a/client-app/app/index.tsx b/client-app/app/index.tsx new file mode 100644 index 0000000..8a3d41c --- /dev/null +++ b/client-app/app/index.tsx @@ -0,0 +1,220 @@ +/** + * Welcome / landing screen. + * - Node URL input with live ping + QR scanner + * - Create / Import account buttons + * Redirects to (app)/chats if key already loaded. + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import { + View, Text, TextInput, Pressable, + ScrollView, Alert, ActivityIndicator, +} from 'react-native'; +import { router } from 'expo-router'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useStore } from '@/lib/store'; +import { saveSettings } from '@/lib/storage'; +import { setNodeUrl, getNetStats } from '@/lib/api'; +import { Button } from '@/components/ui/Button'; + +export default function WelcomeScreen() { + const keyFile = useStore(s => s.keyFile); + const settings = useStore(s => s.settings); + const setSettings = useStore(s => s.setSettings); + + const [nodeInput, setNodeInput] = useState(''); + const [scanning, setScanning] = useState(false); + const [checking, setChecking] = useState(false); + const [nodeOk, setNodeOk] = useState(null); + + const [permission, requestPermission] = useCameraPermissions(); + + useEffect(() => { + if (keyFile) router.replace('/(app)/chats'); + }, [keyFile]); + + useEffect(() => { + setNodeInput(settings.nodeUrl); + }, [settings.nodeUrl]); + + const applyNode = useCallback(async (url: string) => { + const clean = url.trim().replace(/\/$/, ''); + if (!clean) return; + setChecking(true); + setNodeOk(null); + setNodeUrl(clean); + try { + await getNetStats(); + setNodeOk(true); + const next = { ...settings, nodeUrl: clean }; + setSettings(next); + await saveSettings(next); + } catch { + setNodeOk(false); + } finally { + setChecking(false); + } + }, [settings, setSettings]); + + const onQrScanned = useCallback(({ data }: { data: string }) => { + setScanning(false); + let url = data.trim(); + try { const p = JSON.parse(url); if (p.nodeUrl) url = p.nodeUrl; } catch {} + setNodeInput(url); + applyNode(url); + }, [applyNode]); + + const openScanner = async () => { + if (!permission?.granted) { + const { granted } = await requestPermission(); + if (!granted) { + Alert.alert('Camera permission required', 'Allow camera access to scan QR codes.'); + return; + } + } + setScanning(true); + }; + + // ── QR Scanner overlay ─────────────────────────────────────────────────── + if (scanning) { + return ( + + + + + + Point at a DChain node QR code + + + setScanning(false)} + style={{ + position: 'absolute', top: 56, left: 16, + backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 20, + paddingHorizontal: 16, paddingVertical: 8, + }} + > + ✕ Cancel + + + ); + } + + // ── Main screen ────────────────────────────────────────────────────────── + const statusColor = nodeOk === true ? '#3fb950' : nodeOk === false ? '#f85149' : '#8b949e'; + + return ( + + {/* Logo ─ takes remaining space above, centered */} + + + + + + DChain + + + Decentralised E2E-encrypted messenger.{'\n'}Your keys. Your messages. + + + + {/* Bottom section ─ node input + buttons */} + + + {/* Node URL label */} + + Node URL + + + {/* Input row */} + + + {/* Status dot */} + + + { setNodeInput(t); setNodeOk(null); }} + onEndEditing={() => applyNode(nodeInput)} + onSubmitEditing={() => applyNode(nodeInput)} + placeholder="http://192.168.1.10:8081" + placeholderTextColor="#8b949e" + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + returnKeyType="done" + style={{ flex: 1, color: '#fff', fontSize: 14, paddingVertical: 14 }} + /> + + {checking + ? + : nodeOk === true + ? + : nodeOk === false + ? + : null + } + + + {/* QR button */} + ({ + width: 48, alignItems: 'center', justifyContent: 'center', + backgroundColor: '#21262d', borderWidth: 1, borderColor: '#30363d', + borderRadius: 12, opacity: pressed ? 0.7 : 1, + })} + > + + + + + {/* Status text */} + {nodeOk === true && ( + + ✓ Node connected + + )} + {nodeOk === false && ( + + ✗ Cannot reach node — check URL and that the node is running + + )} + + {/* Buttons */} + + + + + + + ); +} diff --git a/client-app/babel.config.js b/client-app/babel.config.js new file mode 100644 index 0000000..d08d04a --- /dev/null +++ b/client-app/babel.config.js @@ -0,0 +1,12 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: [ + ['babel-preset-expo', { jsxImportSource: 'nativewind' }], + 'nativewind/babel', + ], + plugins: [ + 'react-native-reanimated/plugin', // must be last + ], + }; +}; diff --git a/client-app/components/ui/Avatar.tsx b/client-app/components/ui/Avatar.tsx new file mode 100644 index 0000000..8151675 --- /dev/null +++ b/client-app/components/ui/Avatar.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { cn } from '@/lib/utils'; + +/** Deterministic color from a string */ +function colorFor(str: string): string { + const colors = [ + 'bg-blue-600', 'bg-purple-600', 'bg-green-600', + 'bg-pink-600', 'bg-orange-600', 'bg-teal-600', + 'bg-red-600', 'bg-indigo-600', 'bg-cyan-600', + ]; + let h = 0; + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0; + return colors[h % colors.length]; +} + +interface AvatarProps { + name?: string; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +const sizeMap = { + sm: { outer: 'w-8 h-8', text: 'text-sm' }, + md: { outer: 'w-10 h-10', text: 'text-base' }, + lg: { outer: 'w-14 h-14', text: 'text-xl' }, +}; + +export function Avatar({ name = '?', size = 'md', className }: AvatarProps) { + const initials = name.slice(0, 2).toUpperCase(); + const { outer, text } = sizeMap[size]; + return ( + + {initials} + + ); +} diff --git a/client-app/components/ui/Badge.tsx b/client-app/components/ui/Badge.tsx new file mode 100644 index 0000000..aab9fa7 --- /dev/null +++ b/client-app/components/ui/Badge.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { cn } from '@/lib/utils'; + +interface BadgeProps { + label: string | number; + variant?: 'default' | 'success' | 'destructive' | 'muted'; + className?: string; +} + +const variantMap = { + default: 'bg-primary', + success: 'bg-success', + destructive: 'bg-destructive', + muted: 'bg-surfaceHigh border border-border', +}; + +export function Badge({ label, variant = 'default', className }: BadgeProps) { + return ( + + {label} + + ); +} diff --git a/client-app/components/ui/Button.tsx b/client-app/components/ui/Button.tsx new file mode 100644 index 0000000..040b1c4 --- /dev/null +++ b/client-app/components/ui/Button.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Pressable, Text, ActivityIndicator } from 'react-native'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'flex-row items-center justify-center rounded-xl px-5 py-3 active:opacity-80', + { + variants: { + variant: { + default: 'bg-primary', + secondary: 'bg-surfaceHigh border border-border', + destructive: 'bg-destructive', + ghost: 'bg-transparent', + outline: 'bg-transparent border border-primary', + }, + size: { + sm: 'px-3 py-2', + md: 'px-5 py-3', + lg: 'px-6 py-4', + icon: 'p-2', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + }, + }, +); + +const textVariants = cva('font-semibold text-center', { + variants: { + variant: { + default: 'text-white', + secondary: 'text-white', + destructive: 'text-white', + ghost: 'text-primary', + outline: 'text-primary', + }, + size: { + sm: 'text-sm', + md: 'text-base', + lg: 'text-lg', + icon: 'text-base', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + }, +}); + +interface ButtonProps extends VariantProps { + onPress?: () => void; + disabled?: boolean; + loading?: boolean; + children: React.ReactNode; + className?: string; +} + +export function Button({ + variant, size, onPress, disabled, loading, children, className, +}: ButtonProps) { + return ( + + {loading + ? + : {children} + } + + ); +} diff --git a/client-app/components/ui/Card.tsx b/client-app/components/ui/Card.tsx new file mode 100644 index 0000000..01fe288 --- /dev/null +++ b/client-app/components/ui/Card.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { View } from 'react-native'; +import { cn } from '@/lib/utils'; + +interface CardProps { + children: React.ReactNode; + className?: string; +} + +export function Card({ children, className }: CardProps) { + return ( + + {children} + + ); +} diff --git a/client-app/components/ui/Input.tsx b/client-app/components/ui/Input.tsx new file mode 100644 index 0000000..7a09b39 --- /dev/null +++ b/client-app/components/ui/Input.tsx @@ -0,0 +1,34 @@ +import React, { forwardRef } from 'react'; +import { TextInput, View, Text, type TextInputProps } from 'react-native'; +import { cn } from '@/lib/utils'; + +interface InputProps extends TextInputProps { + label?: string; + error?: string; + className?: string; +} + +export const Input = forwardRef( + ({ label, error, className, ...props }, ref) => ( + + {label && ( + {label} + )} + + {error && ( + {error} + )} + + ), +); + +Input.displayName = 'Input'; diff --git a/client-app/components/ui/Separator.tsx b/client-app/components/ui/Separator.tsx new file mode 100644 index 0000000..29edd95 --- /dev/null +++ b/client-app/components/ui/Separator.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { View } from 'react-native'; +import { cn } from '@/lib/utils'; + +export function Separator({ className }: { className?: string }) { + return ; +} diff --git a/client-app/components/ui/index.ts b/client-app/components/ui/index.ts new file mode 100644 index 0000000..3af05b2 --- /dev/null +++ b/client-app/components/ui/index.ts @@ -0,0 +1,6 @@ +export { Button } from './Button'; +export { Card } from './Card'; +export { Input } from './Input'; +export { Avatar } from './Avatar'; +export { Badge } from './Badge'; +export { Separator } from './Separator'; diff --git a/client-app/global.css b/client-app/global.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/client-app/global.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/client-app/hooks/useBalance.ts b/client-app/hooks/useBalance.ts new file mode 100644 index 0000000..54d5dac --- /dev/null +++ b/client-app/hooks/useBalance.ts @@ -0,0 +1,94 @@ +/** + * Balance hook — uses the WebSocket gateway to receive instant updates when + * a tx involving the current address is committed, with HTTP polling as a + * graceful fallback for old nodes that don't expose /api/ws. + * + * Flow: + * 1. On mount: immediate HTTP fetch so the UI has a non-zero balance ASAP + * 2. Subscribe to `addr:` on the WS hub + * 3. On every `tx` event, re-fetch balance (cheap — one Badger read server-side) + * 4. If WS disconnects for >15s, fall back to 10-second polling until it reconnects + */ + +import { useEffect, useCallback, useRef } from 'react'; +import { getBalance } from '@/lib/api'; +import { getWSClient } from '@/lib/ws'; +import { useStore } from '@/lib/store'; + +const FALLBACK_POLL_INTERVAL = 10_000; // HTTP poll when WS is down +const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect + +export function useBalance() { + const keyFile = useStore(s => s.keyFile); + const setBalance = useStore(s => s.setBalance); + + const refresh = useCallback(async () => { + if (!keyFile) return; + try { + const bal = await getBalance(keyFile.pub_key); + setBalance(bal); + } catch { + // transient — next call will retry + } + }, [keyFile, setBalance]); + + // --- fallback polling management --- + const pollTimerRef = useRef | null>(null); + const disconnectSinceRef = useRef(null); + const disconnectTORef = useRef | null>(null); + + const startPolling = useCallback(() => { + if (pollTimerRef.current) return; + console.log('[useBalance] WS down for grace period — starting HTTP poll'); + refresh(); + pollTimerRef.current = setInterval(refresh, FALLBACK_POLL_INTERVAL); + }, [refresh]); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (disconnectTORef.current) { + clearTimeout(disconnectTORef.current); + disconnectTORef.current = null; + } + disconnectSinceRef.current = null; + }, []); + + useEffect(() => { + if (!keyFile) return; + const ws = getWSClient(); + + // Immediate HTTP fetch so the UI is not empty while the WS hello arrives. + refresh(); + + // Refresh balance whenever a tx for our address is committed. + const offTx = ws.subscribe('addr:' + keyFile.pub_key, (frame) => { + if (frame.event === 'tx') { + refresh(); + } + }); + + // Manage fallback polling based on WS connection state. + const offConn = ws.onConnectionChange((ok) => { + if (ok) { + stopPolling(); + refresh(); // catch up anything we missed while disconnected + } else if (disconnectTORef.current === null) { + disconnectSinceRef.current = Date.now(); + disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING); + } + }); + + ws.connect(); + + return () => { + offTx(); + offConn(); + stopPolling(); + }; + }, [keyFile, refresh, startPolling, stopPolling]); + + return { refresh }; +} diff --git a/client-app/hooks/useContacts.ts b/client-app/hooks/useContacts.ts new file mode 100644 index 0000000..cf8f9e9 --- /dev/null +++ b/client-app/hooks/useContacts.ts @@ -0,0 +1,80 @@ +/** + * Contacts + inbound request tracking. + * + * - Loads cached contacts from local storage on boot. + * - Subscribes to the address WS topic so a new CONTACT_REQUEST pulls the + * relay contact list immediately (sub-second UX). + * - Keeps a 30 s polling fallback for nodes without WS or while disconnected. + */ + +import { useEffect, useCallback } from 'react'; +import { fetchContactRequests } from '@/lib/api'; +import { getWSClient } from '@/lib/ws'; +import { loadContacts } from '@/lib/storage'; +import { useStore } from '@/lib/store'; + +const FALLBACK_POLL_INTERVAL = 30_000; + +export function useContacts() { + const keyFile = useStore(s => s.keyFile); + const setContacts = useStore(s => s.setContacts); + const setRequests = useStore(s => s.setRequests); + const contacts = useStore(s => s.contacts); + + // Load cached contacts from local storage once + useEffect(() => { + loadContacts().then(setContacts); + }, [setContacts]); + + const pollRequests = useCallback(async () => { + if (!keyFile) return; + try { + const raw = await fetchContactRequests(keyFile.pub_key); + + // Filter out already-accepted contacts + const contactAddresses = new Set(contacts.map(c => c.address)); + + const requests = raw + .filter(r => r.status === 'pending' && !contactAddresses.has(r.requester_pub)) + .map(r => ({ + from: r.requester_pub, + // x25519Pub will be fetched from identity when user taps Accept + x25519Pub: '', + intro: r.intro ?? '', + timestamp: r.created_at, + txHash: r.tx_id, + })); + + setRequests(requests); + } catch { + // Ignore transient network errors + } + }, [keyFile, contacts, setRequests]); + + useEffect(() => { + if (!keyFile) return; + const ws = getWSClient(); + + // Initial load + low-frequency fallback poll (covers missed WS events, + // works even when the node has no WS endpoint). + pollRequests(); + const interval = setInterval(pollRequests, FALLBACK_POLL_INTERVAL); + + // Immediate refresh when a CONTACT_REQUEST / ACCEPT_CONTACT tx addressed + // to us lands on-chain. WS fan-out already filters to our address topic. + const off = ws.subscribe('addr:' + keyFile.pub_key, (frame) => { + if (frame.event === 'tx') { + const d = frame.data as { tx_type?: string } | undefined; + if (d?.tx_type === 'CONTACT_REQUEST' || d?.tx_type === 'ACCEPT_CONTACT') { + pollRequests(); + } + } + }); + ws.connect(); + + return () => { + clearInterval(interval); + off(); + }; + }, [keyFile, pollRequests]); +} diff --git a/client-app/hooks/useMessages.ts b/client-app/hooks/useMessages.ts new file mode 100644 index 0000000..b6ec46e --- /dev/null +++ b/client-app/hooks/useMessages.ts @@ -0,0 +1,123 @@ +/** + * Subscribe to the relay inbox via WebSocket and decrypt incoming envelopes + * for the active chat. Falls back to 30-second polling whenever the WS is + * not connected — preserves correctness on older nodes or flaky networks. + * + * Flow: + * 1. On mount: one HTTP fetch so we have whatever is already in the inbox + * 2. Subscribe to topic `inbox:` — the node pushes a summary + * for each fresh envelope as soon as mailbox.Store() succeeds + * 3. On each push, pull the full envelope list (cheap — bounded by + * MailboxPerRecipientCap) and decrypt anything we haven't seen yet + * 4. If WS disconnects for > 15 seconds, start a 30 s HTTP poll until it + * reconnects + */ + +import { useEffect, useCallback, useRef } from 'react'; +import { fetchInbox } from '@/lib/api'; +import { getWSClient } from '@/lib/ws'; +import { decryptMessage } from '@/lib/crypto'; +import { appendMessage } from '@/lib/storage'; +import { useStore } from '@/lib/store'; + +const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down +const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect + +export function useMessages(contactX25519: string) { + const keyFile = useStore(s => s.keyFile); + const appendMsg = useStore(s => s.appendMessage); + + const pullAndDecrypt = useCallback(async () => { + if (!keyFile || !contactX25519) return; + try { + const envelopes = await fetchInbox(keyFile.x25519_pub); + for (const env of envelopes) { + // Only process messages from this contact + if (env.sender_pub !== contactX25519) continue; + + const text = decryptMessage( + env.ciphertext, + env.nonce, + env.sender_pub, + keyFile.x25519_priv, + ); + if (!text) continue; + + const msg = { + id: `${env.sender_pub}_${env.timestamp}_${env.nonce.slice(0, 8)}`, + from: env.sender_pub, + text, + timestamp: env.timestamp, + mine: false, + }; + appendMsg(contactX25519, msg); + await appendMessage(contactX25519, msg); + } + } catch (e) { + // Don't surface inbox errors aggressively — next event or poll retries + console.warn('[useMessages] pull error:', e); + } + }, [keyFile, contactX25519, appendMsg]); + + // ── Fallback polling state ──────────────────────────────────────────── + const pollTimerRef = useRef | null>(null); + const disconnectTORef = useRef | null>(null); + + const startPolling = useCallback(() => { + if (pollTimerRef.current) return; + console.log('[useMessages] WS down — starting HTTP poll fallback'); + pullAndDecrypt(); + pollTimerRef.current = setInterval(pullAndDecrypt, FALLBACK_POLL_INTERVAL); + }, [pullAndDecrypt]); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (disconnectTORef.current) { + clearTimeout(disconnectTORef.current); + disconnectTORef.current = null; + } + }, []); + + useEffect(() => { + if (!keyFile || !contactX25519) return; + + const ws = getWSClient(); + + // Initial fetch — populate whatever landed before we mounted. + pullAndDecrypt(); + + // Subscribe to our x25519 inbox — the node emits on mailbox.Store. + // Topic filter: only envelopes for ME; we then filter by sender inside + // the handler so we only render messages in THIS chat. + const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => { + if (frame.event !== 'inbox') return; + const d = frame.data as { sender_pub?: string } | undefined; + // Optimisation: if the envelope is from a different peer, skip the + // whole refetch — we'd just drop it in the sender filter below anyway. + if (d?.sender_pub && d.sender_pub !== contactX25519) return; + pullAndDecrypt(); + }); + + // Manage fallback polling based on WS connection state. + const offConn = ws.onConnectionChange((ok) => { + if (ok) { + stopPolling(); + // Catch up anything we missed while disconnected. + pullAndDecrypt(); + } else if (disconnectTORef.current === null) { + disconnectTORef.current = setTimeout(startPolling, WS_GRACE_BEFORE_POLLING); + } + }); + + ws.connect(); + + return () => { + offInbox(); + offConn(); + stopPolling(); + }; + }, [keyFile, contactX25519, pullAndDecrypt, startPolling, stopPolling]); +} diff --git a/client-app/hooks/useWellKnownContracts.ts b/client-app/hooks/useWellKnownContracts.ts new file mode 100644 index 0000000..5e97b22 --- /dev/null +++ b/client-app/hooks/useWellKnownContracts.ts @@ -0,0 +1,61 @@ +/** + * Auto-discover canonical system contracts from the node so the user doesn't + * have to paste contract IDs into settings by hand. + * + * Flow: + * 1. On app boot (and whenever nodeUrl changes), call GET /api/well-known-contracts + * 2. If the node advertises a `username_registry` and the user has not + * manually set `settings.contractId`, auto-populate it and persist. + * 3. A user-supplied contractId is never overwritten — so power users can + * still pin a non-canonical deployment from settings. + */ + +import { useEffect } from 'react'; +import { fetchWellKnownContracts } from '@/lib/api'; +import { saveSettings } from '@/lib/storage'; +import { useStore } from '@/lib/store'; + +export function useWellKnownContracts() { + const nodeUrl = useStore(s => s.settings.nodeUrl); + const contractId = useStore(s => s.settings.contractId); + const settings = useStore(s => s.settings); + const setSettings = useStore(s => s.setSettings); + + useEffect(() => { + let cancelled = false; + + async function run() { + if (!nodeUrl) return; + const res = await fetchWellKnownContracts(); + if (cancelled || !res) return; + + const registry = res.contracts['username_registry']; + if (!registry) return; + + // Always keep the stored contractId in sync with what the node reports + // as canonical. If the user resets their chain or we migrate from a + // WASM contract to the native one, the stale contract_id cached in + // local storage would otherwise keep the client trying to call a + // contract that no longer exists on this chain. + // + // To still support intentional overrides: the UI's "advanced" section + // allows pasting a specific ID — and since that also writes to + // settings.contractId, the loop converges back to whatever the node + // says after a short delay. Operators who want a hard override should + // either run a patched node or pin the value with a wrapper config + // outside the app. + if (registry.contract_id !== contractId) { + const next = { ...settings, contractId: registry.contract_id }; + setSettings({ contractId: registry.contract_id }); + await saveSettings(next); + console.log('[well-known] synced username_registry =', registry.contract_id, + '(was:', contractId || '', ')'); + } + } + + run(); + return () => { cancelled = true; }; + // Re-run when the node URL changes (user switched networks) or when + // contractId is cleared. + }, [nodeUrl, contractId]); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/client-app/lib/api.ts b/client-app/lib/api.ts new file mode 100644 index 0000000..ca254e0 --- /dev/null +++ b/client-app/lib/api.ts @@ -0,0 +1,701 @@ +/** + * DChain REST API client. + * All requests go to the configured node URL (e.g. http://192.168.1.10:8081). + */ + +import type { Envelope, TxRecord, NetStats, Contact } from './types'; + +// ─── Base ───────────────────────────────────────────────────────────────────── + +let _nodeUrl = 'http://localhost:8081'; + +/** + * Listeners invoked AFTER _nodeUrl changes. The WS client registers here so + * that switching nodes in Settings tears down the old socket and re-dials + * the new one (without this, a user who pointed their app at node A would + * keep receiving A's events forever after flipping to B). + */ +const nodeUrlListeners = new Set<(url: string) => void>(); + +export function setNodeUrl(url: string) { + const normalised = url.replace(/\/$/, ''); + if (_nodeUrl === normalised) return; + _nodeUrl = normalised; + for (const fn of nodeUrlListeners) { + try { fn(_nodeUrl); } catch { /* ignore — listeners are best-effort */ } + } +} + +export function getNodeUrl(): string { + return _nodeUrl; +} + +/** Register a callback for node-URL changes. Returns an unsubscribe fn. */ +export function onNodeUrlChange(fn: (url: string) => void): () => void { + nodeUrlListeners.add(fn); + return () => { nodeUrlListeners.delete(fn); }; +} + +async function get(path: string): Promise { + const res = await fetch(`${_nodeUrl}${path}`); + if (!res.ok) throw new Error(`GET ${path} → ${res.status}`); + return res.json() as Promise; +} + +/** + * Enhanced error reporter for POST failures. The node's `jsonErr` writes + * `{"error": "..."}` as the response body; we parse that out so the UI layer + * can show a meaningful message instead of a raw status code. + * + * Rate-limit and timestamp-skew rejections produce specific strings the UI + * can translate to user-friendly Russian via matcher functions below. + */ +async function post(path: string, body: unknown): Promise { + const res = await fetch(`${_nodeUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + // Try to extract {"error":"..."} payload for a cleaner message. + let detail = text; + try { + const parsed = JSON.parse(text); + if (parsed?.error) detail = parsed.error; + } catch { /* keep raw text */ } + // Include HTTP status so `humanizeTxError` can branch on 429/400/etc. + throw new Error(`${res.status}: ${detail}`); + } + return res.json() as Promise; +} + +/** + * Turn a submission error from `post()` / `submitTx()` into a user-facing + * Russian message with actionable hints. Preserves the raw detail at the end + * so advanced users can still copy the original for support. + */ +export function humanizeTxError(e: unknown): string { + const raw = e instanceof Error ? e.message : String(e); + if (raw.startsWith('429')) { + return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.'; + } + if (raw.startsWith('400') && raw.includes('timestamp')) { + return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).'; + } + if (raw.startsWith('400') && raw.includes('signature')) { + return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.'; + } + if (raw.startsWith('400')) { + return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`; + } + if (raw.startsWith('5')) { + return `Ошибка ноды (${raw}). Попробуйте позже.`; + } + // Network-level + if (raw.toLowerCase().includes('network request failed')) { + return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.'; + } + return raw; +} + +// ─── Chain API ──────────────────────────────────────────────────────────────── + +export async function getNetStats(): Promise { + return get('/api/netstats'); +} + +interface AddrResponse { + balance_ut: number; + balance: string; + transactions: Array<{ + id: string; + type: string; + from: string; + to?: string; + amount_ut: number; + fee_ut: number; + time: string; // ISO-8601 e.g. "2025-01-01T12:00:00Z" + block_index: number; + }>; + tx_count: number; + has_more: boolean; +} + +export async function getBalance(pubkey: string): Promise { + const data = await get(`/api/address/${pubkey}`); + return data.balance_ut ?? 0; +} + +/** + * Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction JSON. + * Key facts: + * - `payload` is base64-encoded JSON bytes (Go []byte → base64 in JSON) + * - `signature` is base64-encoded Ed25519 sig (Go []byte → base64 in JSON) + * - `timestamp` is RFC3339 string (Go time.Time → string in JSON) + * - There is NO nonce field; dedup is by `id` + */ +export interface RawTx { + id: string; // "tx-" or sha256-based + type: string; // "TRANSFER", "CONTACT_REQUEST", etc. + from: string; // hex Ed25519 pub key + to: string; // hex Ed25519 pub key (empty string if N/A) + amount: number; // µT (uint64) + fee: number; // µT (uint64) + memo?: string; // optional + payload: string; // base64(json.Marshal(TypeSpecificPayload)) + signature: string; // base64(ed25519.Sign(canonical_bytes, priv)) + timestamp: string; // RFC3339 e.g. "2025-01-01T12:00:00Z" +} + +export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> { + console.log('[submitTx] →', { + id: tx.id, + type: tx.type, + from: tx.from.slice(0, 12) + '…', + to: tx.to ? tx.to.slice(0, 12) + '…' : '', + amount: tx.amount, + fee: tx.fee, + timestamp: tx.timestamp, + transport: 'auto', + }); + + // Try the WebSocket path first: no HTTP round-trip, and we get a proper + // submit_ack correlated back to our tx id. Falls through to HTTP if WS is + // unavailable (old node, disconnected, timeout, etc.) so legacy setups + // keep working. + try { + // Lazy import avoids a circular dep with lib/ws.ts (which itself + // imports getNodeUrl from this module). + const { getWSClient } = await import('./ws'); + const ws = getWSClient(); + if (ws.isConnected()) { + try { + const res = await ws.submitTx(tx); + console.log('[submitTx] ← accepted via WS', res); + return { id: res.id || tx.id, status: 'accepted' }; + } catch (e) { + console.warn('[submitTx] WS path failed, falling back to HTTP:', e); + } + } + } catch { /* circular import edge case — ignore and use HTTP */ } + + try { + const res = await post<{ id: string; status: string }>('/api/tx', tx); + console.log('[submitTx] ← accepted via HTTP', res); + return res; + } catch (e) { + console.warn('[submitTx] ← rejected', e); + throw e; + } +} + +export async function getTxHistory(pubkey: string, limit = 50): Promise { + const data = await get(`/api/address/${pubkey}?limit=${limit}`); + return (data.transactions ?? []).map(tx => ({ + hash: tx.id, + type: tx.type, + from: tx.from, + to: tx.to, + amount: tx.amount_ut, + fee: tx.fee_ut, + // Convert ISO-8601 string → unix seconds + timestamp: tx.time ? Math.floor(new Date(tx.time).getTime() / 1000) : 0, + status: 'confirmed' as const, + })); +} + +// ─── Relay API ──────────────────────────────────────────────────────────────── + +export interface SendEnvelopeReq { + sender_pub: string; + recipient_pub: string; + nonce: string; + ciphertext: string; +} + +export async function sendEnvelope(env: SendEnvelopeReq): Promise<{ ok: boolean }> { + return post<{ ok: boolean }>('/api/relay/send', env); +} + +export async function fetchInbox(x25519PubHex: string): Promise { + return get(`/api/relay/inbox?pub=${x25519PubHex}`); +} + +// ─── Contact requests (on-chain) ───────────────────────────────────────────── + +/** + * Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=... + * The response shape is { pub, count, contacts: ContactInfo[] }. + */ +export interface ContactRequestRaw { + requester_pub: string; // Ed25519 pubkey of requester + requester_addr: string; // DChain address (DC…) + status: string; // "pending" | "accepted" | "blocked" + intro: string; // plaintext intro message (may be empty) + fee_ut: number; // anti-spam fee paid in µT + tx_id: string; // transaction ID + created_at: number; // unix seconds +} + +export async function fetchContactRequests(edPubHex: string): Promise { + const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`); + return data.contacts ?? []; +} + +// ─── Identity API ───────────────────────────────────────────────────────────── + +export interface IdentityInfo { + pub_key: string; + address: string; + x25519_pub: string; // hex Curve25519 key; empty string if not published + nickname: string; + registered: boolean; +} + +/** Fetch identity info for any pubkey or DC address. Returns null on 404. */ +export async function getIdentity(pubkeyOrAddr: string): Promise { + try { + return await get(`/api/identity/${pubkeyOrAddr}`); + } catch { + return null; + } +} + +// ─── Contract API ───────────────────────────────────────────────────────────── + +/** + * Response shape from GET /api/contracts/{id}/state/{key}. + * The node handler (node/api_contract.go:handleContractState) returns either: + * { value_b64: null, value_hex: null, ... } when the key is missing + * or + * { value_b64: "...", value_hex: "...", value_u64?: 0 } when the key exists. + */ +interface ContractStateResponse { + contract_id: string; + key: string; + value_b64: string | null; + value_hex: string | null; + value_u64?: number; +} + +/** + * Decode a hex string (lowercase/uppercase) back to the original string value + * it represents. The username registry contract stores values as plain ASCII + * bytes (pubkey hex strings / username strings), so `value_hex` on the wire + * is the hex-encoding of UTF-8 bytes. We hex-decode to bytes, then interpret + * those bytes as UTF-8. + */ +function hexToUtf8(hex: string): string { + if (hex.length % 2 !== 0) return ''; + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16); + } + // TextDecoder is available in Hermes / RN's JS runtime. + try { + return new TextDecoder('utf-8').decode(bytes); + } catch { + // Fallback for environments without TextDecoder. + let s = ''; + for (const b of bytes) s += String.fromCharCode(b); + return s; + } +} + +/** username → address (hex pubkey). Returns null if unregistered. */ +export async function resolveUsername(contractId: string, username: string): Promise { + try { + const data = await get(`/api/contracts/${contractId}/state/name:${username}`); + if (!data.value_hex) return null; + const decoded = hexToUtf8(data.value_hex).trim(); + return decoded || null; + } catch { + return null; + } +} + +/** address (hex pubkey) → username. Returns null if this address hasn't registered a name. */ +export async function reverseResolve(contractId: string, address: string): Promise { + try { + const data = await get(`/api/contracts/${contractId}/state/addr:${address}`); + if (!data.value_hex) return null; + const decoded = hexToUtf8(data.value_hex).trim(); + return decoded || null; + } catch { + return null; + } +} + +// ─── Well-known contracts ───────────────────────────────────────────────────── + +/** + * Per-entry shape returned by GET /api/well-known-contracts. + * Matches node/api_well_known.go:WellKnownContract. + */ +export interface WellKnownContract { + contract_id: string; + name: string; + version?: string; + deployed_at: number; +} + +/** + * Response from GET /api/well-known-contracts. + * `contracts` is keyed by ABI name (e.g. "username_registry"). + */ +export interface WellKnownResponse { + count: number; + contracts: Record; +} + +/** + * Fetch the node's view of canonical system contracts so the client doesn't + * have to force the user to paste contract IDs into settings. + * + * The node returns the earliest-deployed contract per ABI name; this means + * every peer in the same chain reports the same mapping. + * + * Returns `null` on failure (old node, network hiccup, endpoint missing). + */ +export async function fetchWellKnownContracts(): Promise { + try { + return await get('/api/well-known-contracts'); + } catch { + return null; + } +} + +// ─── Node version / update-check ───────────────────────────────────────────── +// +// The three calls below let the client: +// 1. fetchNodeVersion() — see what tag/commit/features the connected node +// exposes. Used on first boot + on every chain-switch so we can warn if +// a required feature is missing. +// 2. checkNodeVersion(required) — thin wrapper that returns {supported, +// missing} by diffing a client-expected feature list against the node's. +// 3. fetchUpdateCheck() — ask the node whether its operator has a newer +// release available from their configured release source (Gitea). For +// messenger UX this is purely informational ("the node you're on is N +// versions behind"), never used to update the node automatically. + +/** The shape returned by GET /api/well-known-version. */ +export interface NodeVersionInfo { + node_version: string; + protocol_version: number; + features: string[]; + chain_id?: string; + build?: { + tag: string; + commit: string; + date: string; + dirty: string; + }; +} + +/** Client-expected protocol version. Bumped only when wire-protocol breaks. */ +export const CLIENT_PROTOCOL_VERSION = 1; + +/** + * Minimum feature set this client build relies on. A node missing any of + * these is considered "unsupported" — caller should surface an upgrade + * prompt to the user instead of silently failing on the first feature call. + */ +export const CLIENT_REQUIRED_FEATURES = [ + 'chain_id', + 'identity_registry', + 'onboarding_api', + 'relay_mailbox', + 'ws_submit_tx', +]; + +/** GET /api/well-known-version. Returns null on failure (old node, network hiccup). */ +export async function fetchNodeVersion(): Promise { + try { + return await get('/api/well-known-version'); + } catch { + return null; + } +} + +/** + * Check whether the connected node supports this client's required features + * and protocol version. Returns a decision blob the UI can render directly. + * + * { supported: true } → everything fine + * { supported: false, reason: "...", ... } → show update prompt + * { supported: null, reason: "unreachable" } → couldn't reach the endpoint, + * likely old node — assume OK + * but warn quietly. + */ +export async function checkNodeVersion( + required: string[] = CLIENT_REQUIRED_FEATURES, +): Promise<{ + supported: boolean | null; + reason?: string; + missing?: string[]; + info?: NodeVersionInfo; +}> { + const info = await fetchNodeVersion(); + if (!info) { + return { supported: null, reason: 'unreachable' }; + } + if (info.protocol_version !== CLIENT_PROTOCOL_VERSION) { + return { + supported: false, + reason: `protocol v${info.protocol_version} but client expects v${CLIENT_PROTOCOL_VERSION}`, + info, + }; + } + const have = new Set(info.features || []); + const missing = required.filter((f) => !have.has(f)); + if (missing.length > 0) { + return { + supported: false, + reason: `node missing features: ${missing.join(', ')}`, + missing, + info, + }; + } + return { supported: true, info }; +} + +/** The shape returned by GET /api/update-check. */ +export interface UpdateCheckResponse { + current: { tag: string; commit: string; date: string; dirty: string }; + latest?: { tag: string; commit?: string; url?: string; published_at?: string }; + update_available: boolean; + checked_at: string; + source?: string; +} + +/** + * GET /api/update-check. Returns null when: + * - the node operator hasn't configured DCHAIN_UPDATE_SOURCE_URL (503), + * - upstream Gitea call failed (502), + * - request errored out. + * All three are non-fatal for the client; the UI just doesn't render the + * "update available" banner. + */ +export async function fetchUpdateCheck(): Promise { + try { + return await get('/api/update-check'); + } catch { + return null; + } +} + +// ─── Transaction builder helpers ───────────────────────────────────────────── + +import { signBase64, bytesToBase64 } from './crypto'; + +/** Minimum blockchain tx fee paid to the block validator (matches blockchain.MinFee = 1000 µT). */ +const MIN_TX_FEE = 1000; + +const _encoder = new TextEncoder(); + +/** RFC3339 timestamp with second precision — matches Go time.Time JSON output. */ +function rfc3339Now(): string { + const d = new Date(); + d.setMilliseconds(0); + // toISOString() gives "2025-01-01T12:00:00.000Z" → replace ".000Z" with "Z" + return d.toISOString().replace('.000Z', 'Z'); +} + +/** Unique transaction ID (nanoseconds-like using Date.now + random). */ +function newTxID(): string { + return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`; +} + +/** + * Canonical bytes for signing — must match identity.txSignBytes in Go exactly. + * + * Go struct field order: id, type, from, to, amount, fee, payload, timestamp. + * JS JSON.stringify preserves insertion order, so we rely on that here. + */ +function txCanonicalBytes(tx: { + id: string; type: string; from: string; to: string; + amount: number; fee: number; payload: string; timestamp: string; +}): Uint8Array { + const s = JSON.stringify({ + id: tx.id, + type: tx.type, + from: tx.from, + to: tx.to, + amount: tx.amount, + fee: tx.fee, + payload: tx.payload, + timestamp: tx.timestamp, + }); + return _encoder.encode(s); +} + +/** Encode a JS string (UTF-8) to base64. */ +function strToBase64(s: string): string { + return bytesToBase64(_encoder.encode(s)); +} + +export function buildTransferTx(params: { + from: string; + to: string; + amount: number; + fee: number; + privKey: string; + memo?: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payloadObj = params.memo ? { memo: params.memo } : {}; + const payload = strToBase64(JSON.stringify(payloadObj)); + + const canonical = txCanonicalBytes({ + id, type: 'TRANSFER', from: params.from, to: params.to, + amount: params.amount, fee: params.fee, payload, timestamp, + }); + + return { + id, type: 'TRANSFER', from: params.from, to: params.to, + amount: params.amount, fee: params.fee, + memo: params.memo, + payload, timestamp, + signature: signBase64(canonical, params.privKey), + }; +} + +/** + * CONTACT_REQUEST transaction. + * + * blockchain.Transaction fields: + * Amount = contactFee — anti-spam fee, paid directly to recipient (>= 5000 µT) + * Fee = MIN_TX_FEE — blockchain tx fee to the block validator (1000 µT) + * Payload = ContactRequestPayload { intro? } as base64 JSON bytes + */ +export function buildContactRequestTx(params: { + from: string; // sender Ed25519 pubkey + to: string; // recipient Ed25519 pubkey + contactFee: number; // anti-spam amount paid to recipient (>= 5000 µT) + intro?: string; // optional plaintext intro message (≤ 280 chars) + privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + // Payload matches ContactRequestPayload{Intro: "..."} in Go + const payloadObj = params.intro ? { intro: params.intro } : {}; + const payload = strToBase64(JSON.stringify(payloadObj)); + + const canonical = txCanonicalBytes({ + id, type: 'CONTACT_REQUEST', from: params.from, to: params.to, + amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp, + }); + + return { + id, type: 'CONTACT_REQUEST', from: params.from, to: params.to, + amount: params.contactFee, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canonical, params.privKey), + }; +} + +/** + * ACCEPT_CONTACT transaction. + * AcceptContactPayload is an empty struct in Go — no fields needed. + */ +export function buildAcceptContactTx(params: { + from: string; // acceptor Ed25519 pubkey (us — the recipient of the request) + to: string; // requester Ed25519 pubkey + privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64(JSON.stringify({})); // AcceptContactPayload{} + + const canonical = txCanonicalBytes({ + id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to, + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + }); + + return { + id, type: 'ACCEPT_CONTACT', from: params.from, to: params.to, + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canonical, params.privKey), + }; +} + +// ─── Contract call ──────────────────────────────────────────────────────────── + +/** Minimum base fee for CALL_CONTRACT (matches blockchain.MinCallFee). */ +const MIN_CALL_FEE = 1000; + +/** + * CALL_CONTRACT transaction. + * + * Payload shape (CallContractPayload): + * { contract_id, method, args_json?, gas_limit } + * + * `amount` is the payment attached to the call and made available to the + * contract as `tx.Amount`. Whether it's collected depends on the contract + * — e.g. username_registry.register requires exactly 10_000 µT. Contracts + * that don't need payment should be called with `amount: 0` (default). + * + * The on-chain tx envelope carries `amount` openly, so the explorer shows + * the exact cost of a call rather than hiding it in a contract-internal + * debit — this was the UX motivation for this field. + * + * `fee` is the NETWORK fee paid to the block validator (not the contract). + * `gas` costs are additional and billed at the live gas price. + */ +export function buildCallContractTx(params: { + from: string; + contractId: string; + method: string; + args?: unknown[]; // JSON-serializable arguments + amount?: number; // µT attached to the call (default 0) + gasLimit?: number; // default 1_000_000 + privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const amount = params.amount ?? 0; + + const argsJson = params.args && params.args.length > 0 + ? JSON.stringify(params.args) + : ''; + + const payloadObj = { + contract_id: params.contractId, + method: params.method, + args_json: argsJson, + gas_limit: params.gasLimit ?? 1_000_000, + }; + const payload = strToBase64(JSON.stringify(payloadObj)); + + const canonical = txCanonicalBytes({ + id, type: 'CALL_CONTRACT', from: params.from, to: '', + amount, fee: MIN_CALL_FEE, payload, timestamp, + }); + + return { + id, type: 'CALL_CONTRACT', from: params.from, to: '', + amount, fee: MIN_CALL_FEE, payload, timestamp, + signature: signBase64(canonical, params.privKey), + }; +} + +/** + * Flat registration fee for a username, in µT. + * + * The native username_registry charges a single flat fee (10 000 µT = 0.01 T) + * per register() call regardless of name length, replacing the earlier + * length-based formula. Flat pricing is easier to communicate and the + * 4-char minimum (enforced both in the client UI and the on-chain contract) + * already removes the squatting pressure that tiered pricing mitigated. + */ +export const USERNAME_REGISTRATION_FEE = 10_000; + +/** Minimum/maximum allowed username length. Match blockchain/native_username.go. */ +export const MIN_USERNAME_LENGTH = 4; +export const MAX_USERNAME_LENGTH = 32; + +/** @deprecated Kept for backward compatibility; always returns the flat fee. */ +export function usernameRegistrationFee(_name: string): number { + return USERNAME_REGISTRATION_FEE; +} diff --git a/client-app/lib/crypto.ts b/client-app/lib/crypto.ts new file mode 100644 index 0000000..8f649f5 --- /dev/null +++ b/client-app/lib/crypto.ts @@ -0,0 +1,156 @@ +/** + * Cryptographic operations for DChain messenger. + * + * Ed25519 — transaction signing (via TweetNaCl sign) + * X25519 — Diffie-Hellman key exchange for NaCl box + * NaCl box — authenticated encryption for relay messages + */ + +import nacl from 'tweetnacl'; +import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util'; +import { getRandomBytes } from 'expo-crypto'; +import type { KeyFile } from './types'; + +// ─── PRNG ───────────────────────────────────────────────────────────────────── +// TweetNaCl looks for window.crypto which doesn't exist in React Native/Hermes. +// Wire nacl to expo-crypto which uses the platform's secure RNG natively. +nacl.setPRNG((output: Uint8Array, length: number) => { + const bytes = getRandomBytes(length); + for (let i = 0; i < length; i++) output[i] = bytes[i]; +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error('odd hex length'); + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +// ─── Key generation ─────────────────────────────────────────────────────────── + +/** + * Generate a new identity: Ed25519 signing keys + X25519 encryption keys. + * Returns a KeyFile compatible with the Go node format. + */ +export function generateKeyFile(): KeyFile { + // Ed25519 for signing / blockchain identity + const signKP = nacl.sign.keyPair(); + + // X25519 for NaCl box encryption + // nacl.box.keyPair() returns Curve25519 keys + const boxKP = nacl.box.keyPair(); + + return { + pub_key: bytesToHex(signKP.publicKey), + priv_key: bytesToHex(signKP.secretKey), + x25519_pub: bytesToHex(boxKP.publicKey), + x25519_priv: bytesToHex(boxKP.secretKey), + }; +} + +// ─── NaCl box encryption ────────────────────────────────────────────────────── + +/** + * Encrypt a plaintext message using NaCl box. + * Sender uses their X25519 secret key + recipient's X25519 public key. + * Returns { nonce, ciphertext } as hex strings. + */ +export function encryptMessage( + plaintext: string, + senderSecretHex: string, + recipientPubHex: string, +): { nonce: string; ciphertext: string } { + const nonce = nacl.randomBytes(nacl.box.nonceLength); + const message = decodeUTF8(plaintext); + const secretKey = hexToBytes(senderSecretHex); + const publicKey = hexToBytes(recipientPubHex); + + const box = nacl.box(message, nonce, publicKey, secretKey); + return { + nonce: bytesToHex(nonce), + ciphertext: bytesToHex(box), + }; +} + +/** + * Decrypt a NaCl box. + * Recipient uses their X25519 secret key + sender's X25519 public key. + */ +export function decryptMessage( + ciphertextHex: string, + nonceHex: string, + senderPubHex: string, + recipientSecHex: string, +): string | null { + try { + const ciphertext = hexToBytes(ciphertextHex); + const nonce = hexToBytes(nonceHex); + const senderPub = hexToBytes(senderPubHex); + const secretKey = hexToBytes(recipientSecHex); + + const plain = nacl.box.open(ciphertext, nonce, senderPub, secretKey); + if (!plain) return null; + return encodeUTF8(plain); + } catch { + return null; + } +} + +// ─── Ed25519 signing ────────────────────────────────────────────────────────── + +/** + * Sign arbitrary data with the Ed25519 private key. + * Returns signature as hex. + */ +export function sign(data: Uint8Array, privKeyHex: string): string { + const secretKey = hexToBytes(privKeyHex); + const sig = nacl.sign.detached(data, secretKey); + return bytesToHex(sig); +} + +/** + * Sign arbitrary data with the Ed25519 private key. + * Returns signature as base64 — this is the format the Go blockchain node + * expects ([]byte fields are base64 in JSON). + */ +export function signBase64(data: Uint8Array, privKeyHex: string): string { + const secretKey = hexToBytes(privKeyHex); + const sig = nacl.sign.detached(data, secretKey); + return bytesToBase64(sig); +} + +/** Encode bytes as base64. Works on Hermes (btoa is available since RN 0.71). */ +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Verify an Ed25519 signature. + */ +export function verify(data: Uint8Array, sigHex: string, pubKeyHex: string): boolean { + try { + return nacl.sign.detached.verify(data, hexToBytes(sigHex), hexToBytes(pubKeyHex)); + } catch { + return false; + } +} + +// ─── Address helpers ────────────────────────────────────────────────────────── + +/** Truncate a long hex address for display: 8...8 */ +export function shortAddr(hex: string, chars = 8): string { + if (hex.length <= chars * 2 + 3) return hex; + return `${hex.slice(0, chars)}…${hex.slice(-chars)}`; +} diff --git a/client-app/lib/storage.ts b/client-app/lib/storage.ts new file mode 100644 index 0000000..1f2e94a --- /dev/null +++ b/client-app/lib/storage.ts @@ -0,0 +1,101 @@ +/** + * Persistent storage for keys and app settings. + * On mobile: expo-secure-store for key material, AsyncStorage for settings. + * On web: falls back to localStorage (dev only). + */ + +import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { KeyFile, Contact, NodeSettings } from './types'; + +// ─── Keys ───────────────────────────────────────────────────────────────────── + +const KEYFILE_KEY = 'dchain_keyfile'; +const CONTACTS_KEY = 'dchain_contacts'; +const SETTINGS_KEY = 'dchain_settings'; +const CHATS_KEY = 'dchain_chats'; + +/** Save the key file in secure storage (encrypted on device). */ +export async function saveKeyFile(kf: KeyFile): Promise { + await SecureStore.setItemAsync(KEYFILE_KEY, JSON.stringify(kf)); +} + +/** Load key file. Returns null if not set. */ +export async function loadKeyFile(): Promise { + const raw = await SecureStore.getItemAsync(KEYFILE_KEY); + if (!raw) return null; + return JSON.parse(raw) as KeyFile; +} + +/** Delete key file (logout / factory reset). */ +export async function deleteKeyFile(): Promise { + await SecureStore.deleteItemAsync(KEYFILE_KEY); +} + +// ─── Node settings ───────────────────────────────────────────────────────────── + +const DEFAULT_SETTINGS: NodeSettings = { + nodeUrl: 'http://localhost:8081', + contractId: '', +}; + +export async function loadSettings(): Promise { + const raw = await AsyncStorage.getItem(SETTINGS_KEY); + if (!raw) return DEFAULT_SETTINGS; + return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }; +} + +export async function saveSettings(s: Partial): Promise { + const current = await loadSettings(); + await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...s })); +} + +// ─── Contacts ───────────────────────────────────────────────────────────────── + +export async function loadContacts(): Promise { + const raw = await AsyncStorage.getItem(CONTACTS_KEY); + if (!raw) return []; + return JSON.parse(raw) as Contact[]; +} + +export async function saveContact(c: Contact): Promise { + const contacts = await loadContacts(); + const idx = contacts.findIndex(x => x.address === c.address); + if (idx >= 0) contacts[idx] = c; + else contacts.push(c); + await AsyncStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts)); +} + +export async function deleteContact(address: string): Promise { + const contacts = await loadContacts(); + await AsyncStorage.setItem( + CONTACTS_KEY, + JSON.stringify(contacts.filter(c => c.address !== address)), + ); +} + +// ─── Message cache (per-chat local store) ──────────────────────────────────── + +export interface CachedMessage { + id: string; + from: string; + text: string; + timestamp: number; + mine: boolean; +} + +export async function loadMessages(chatId: string): Promise { + const raw = await AsyncStorage.getItem(`${CHATS_KEY}_${chatId}`); + if (!raw) return []; + return JSON.parse(raw) as CachedMessage[]; +} + +export async function appendMessage(chatId: string, msg: CachedMessage): Promise { + const msgs = await loadMessages(chatId); + // Deduplicate by id + if (msgs.find(m => m.id === msg.id)) return; + msgs.push(msg); + // Keep last 500 messages per chat + const trimmed = msgs.slice(-500); + await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed)); +} diff --git a/client-app/lib/store.ts b/client-app/lib/store.ts new file mode 100644 index 0000000..7e4a763 --- /dev/null +++ b/client-app/lib/store.ts @@ -0,0 +1,103 @@ +/** + * Global app state via Zustand. + * Keeps runtime state; persistent data lives in storage.ts. + */ + +import { create } from 'zustand'; +import type { KeyFile, Contact, Chat, Message, ContactRequest, NodeSettings } from './types'; + +interface AppState { + // Identity + keyFile: KeyFile | null; + username: string | null; + setKeyFile: (kf: KeyFile | null) => void; + setUsername: (u: string | null) => void; + + // Node settings + settings: NodeSettings; + setSettings: (s: Partial) => void; + + // Contacts + contacts: Contact[]; + setContacts: (contacts: Contact[]) => void; + upsertContact: (c: Contact) => void; + + // Chats (derived from contacts + messages) + chats: Chat[]; + setChats: (chats: Chat[]) => void; + + // Active chat messages + messages: Record; // key: contactAddress + setMessages: (chatId: string, msgs: Message[]) => void; + appendMessage: (chatId: string, msg: Message) => void; + + // Contact requests (pending) + requests: ContactRequest[]; + setRequests: (reqs: ContactRequest[]) => void; + + // Balance + balance: number; + setBalance: (b: number) => void; + + // Loading / error states + loading: boolean; + setLoading: (v: boolean) => void; + error: string | null; + setError: (e: string | null) => void; + + // Nonce cache (to avoid refetching) + nonce: number; + setNonce: (n: number) => void; +} + +export const useStore = create((set, get) => ({ + keyFile: null, + username: null, + setKeyFile: (kf) => set({ keyFile: kf }), + setUsername: (u) => set({ username: u }), + + settings: { + nodeUrl: 'http://localhost:8081', + contractId: '', + }, + setSettings: (s) => set(state => ({ settings: { ...state.settings, ...s } })), + + contacts: [], + setContacts: (contacts) => set({ contacts }), + upsertContact: (c) => set(state => { + const idx = state.contacts.findIndex(x => x.address === c.address); + if (idx >= 0) { + const updated = [...state.contacts]; + updated[idx] = c; + return { contacts: updated }; + } + return { contacts: [...state.contacts, c] }; + }), + + chats: [], + setChats: (chats) => set({ chats }), + + messages: {}, + setMessages: (chatId, msgs) => set(state => ({ + messages: { ...state.messages, [chatId]: msgs }, + })), + appendMessage: (chatId, msg) => set(state => { + const current = state.messages[chatId] ?? []; + if (current.find(m => m.id === msg.id)) return {}; + return { messages: { ...state.messages, [chatId]: [...current, msg] } }; + }), + + requests: [], + setRequests: (reqs) => set({ requests: reqs }), + + balance: 0, + setBalance: (b) => set({ balance: b }), + + loading: false, + setLoading: (v) => set({ loading: v }), + error: null, + setError: (e) => set({ error: e }), + + nonce: 0, + setNonce: (n) => set({ nonce: n }), +})); diff --git a/client-app/lib/types.ts b/client-app/lib/types.ts new file mode 100644 index 0000000..0c1181a --- /dev/null +++ b/client-app/lib/types.ts @@ -0,0 +1,86 @@ +// ─── Key material ──────────────────────────────────────────────────────────── + +export interface KeyFile { + pub_key: string; // hex Ed25519 public key (32 bytes) + priv_key: string; // hex Ed25519 private key (64 bytes) + x25519_pub: string; // hex X25519 public key (32 bytes) + x25519_priv: string; // hex X25519 private key (32 bytes) +} + +// ─── Contact ───────────────────────────────────────────────────────────────── + +export interface Contact { + address: string; // Ed25519 pubkey hex — blockchain address + x25519Pub: string; // X25519 pubkey hex — encryption key + username?: string; // @name from registry contract + alias?: string; // local nickname + addedAt: number; // unix ms +} + +// ─── Messages ───────────────────────────────────────────────────────────────── + +export interface Envelope { + sender_pub: string; // X25519 hex + recipient_pub: string; // X25519 hex + nonce: string; // hex 24 bytes + ciphertext: string; // hex NaCl box + timestamp: number; // unix seconds +} + +export interface Message { + id: string; + from: string; // X25519 pubkey of sender + text: string; + timestamp: number; + mine: boolean; +} + +// ─── Chat ──────────────────────────────────────────────────────────────────── + +export interface Chat { + contactAddress: string; // Ed25519 pubkey hex + contactX25519: string; // X25519 pubkey hex + username?: string; + alias?: string; + lastMessage?: string; + lastTime?: number; + unread: number; +} + +// ─── Contact request ───────────────────────────────────────────────────────── + +export interface ContactRequest { + from: string; // Ed25519 pubkey hex + x25519Pub: string; // X25519 pubkey hex; empty until fetched from identity + username?: string; + intro: string; // plaintext intro (stored on-chain) + timestamp: number; + txHash: string; +} + +// ─── Transaction ───────────────────────────────────────────────────────────── + +export interface TxRecord { + hash: string; + type: string; + from: string; + to?: string; + amount?: number; + fee: number; + timestamp: number; + status: 'confirmed' | 'pending'; +} + +// ─── Node info ─────────────────────────────────────────────────────────────── + +export interface NetStats { + total_blocks: number; + total_txs: number; + peer_count: number; + chain_id: string; +} + +export interface NodeSettings { + nodeUrl: string; + contractId: string; // username_registry contract +} diff --git a/client-app/lib/utils.ts b/client-app/lib/utils.ts new file mode 100644 index 0000000..19f61f5 --- /dev/null +++ b/client-app/lib/utils.ts @@ -0,0 +1,35 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +/** Format µT amount to human-readable string */ +export function formatAmount(microTokens: number | undefined | null): string { + if (microTokens == null) return '—'; + if (microTokens >= 1_000_000) return `${(microTokens / 1_000_000).toFixed(2)} T`; + if (microTokens >= 1_000) return `${(microTokens / 1_000).toFixed(1)} mT`; + return `${microTokens} µT`; +} + +/** Format unix seconds to relative time */ +export function relativeTime(unixSeconds: number | undefined | null): string { + if (!unixSeconds) return ''; + const diff = Date.now() / 1000 - unixSeconds; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return new Date(unixSeconds * 1000).toLocaleDateString(); +} + +/** Format unix seconds to HH:MM */ +export function formatTime(unixSeconds: number | undefined | null): string { + if (!unixSeconds) return ''; + return new Date(unixSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +/** Generate a random nonce string */ +export function randomId(): string { + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} diff --git a/client-app/lib/ws.ts b/client-app/lib/ws.ts new file mode 100644 index 0000000..6683ac9 --- /dev/null +++ b/client-app/lib/ws.ts @@ -0,0 +1,401 @@ +/** + * DChain WebSocket client — replaces balance / inbox / contacts polling with + * server-push. Matches `node/ws.go` exactly. + * + * Usage: + * const ws = getWSClient(); + * ws.connect(); // idempotent + * const off = ws.subscribe('addr:ab12…', ev => { ... }); + * // later: + * off(); // unsubscribe + stop handler + * ws.disconnect(); + * + * Features: + * - Auto-reconnect with exponential backoff (1s → 30s cap). + * - Re-subscribes all topics after a reconnect. + * - `hello` frame exposes chain_id + tip_height for connection state UI. + * - Degrades silently if the endpoint returns 501 (old node without WS). + */ + +import { getNodeUrl, onNodeUrlChange } from './api'; +import { sign } from './crypto'; + +export type WSEventName = + | 'hello' + | 'block' + | 'tx' + | 'contract_log' + | 'inbox' + | 'typing' + | 'pong' + | 'error' + | 'subscribed' + | 'submit_ack' + | 'lag'; + +export interface WSFrame { + event: WSEventName; + data?: unknown; + topic?: string; + msg?: string; + chain_id?: string; + tip_height?: number; + /** Server-issued nonce in the hello frame; client signs it for auth. */ + auth_nonce?: string; + // submit_ack fields + id?: string; + status?: 'accepted' | 'rejected'; + reason?: string; +} + +type Handler = (frame: WSFrame) => void; + +class WSClient { + private ws: WebSocket | null = null; + private url: string | null = null; + private reconnectMs: number = 1000; + private closing: boolean = false; + + /** topic → set of handlers interested in frames for this topic */ + private handlers: Map> = new Map(); + /** topics we want the server to push — replayed on every reconnect */ + private wantedTopics: Set = new Set(); + + private connectionListeners: Set<(ok: boolean, err?: string) => void> = new Set(); + private helloInfo: { chainId?: string; tipHeight?: number; authNonce?: string } = {}; + + /** + * Credentials used for auto-auth on every (re)connect. The signer runs on + * each hello frame so scoped subscriptions (addr:*, inbox:*) are accepted. + * Without these, subscribe requests to scoped topics get rejected by the + * server; global topics (blocks, tx, …) still work unauthenticated. + */ + private authCreds: { pubKey: string; privKey: string } | null = null; + + /** Current connection state (read-only for UI). */ + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + getHelloInfo(): { chainId?: string; tipHeight?: number } { + return this.helloInfo; + } + + /** Subscribe to a connection-state listener — fires on connect/disconnect. */ + onConnectionChange(cb: (ok: boolean, err?: string) => void): () => void { + this.connectionListeners.add(cb); + return () => this.connectionListeners.delete(cb) as unknown as void; + } + private fireConnectionChange(ok: boolean, err?: string) { + for (const cb of this.connectionListeners) { + try { cb(ok, err); } catch { /* noop */ } + } + } + + /** + * Register the Ed25519 keypair used for auto-auth. The signer runs on each + * (re)connect against the server-issued nonce so the connection is bound + * to this identity. Pass null to disable auth (only global topics will + * work — useful for observers). + */ + setAuthCreds(creds: { pubKey: string; privKey: string } | null): void { + this.authCreds = creds; + // If we're already connected, kick off auth immediately. + if (creds && this.isConnected() && this.helloInfo.authNonce) { + this.sendAuth(this.helloInfo.authNonce); + } + } + + /** Idempotent connect. Call once on app boot. */ + connect(): void { + const base = getNodeUrl(); + const newURL = base.replace(/^http/, 'ws') + '/api/ws'; + if (this.ws) { + const state = this.ws.readyState; + // Already pointing at this URL and connected / connecting — nothing to do. + if (this.url === newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) { + return; + } + // URL changed (operator flipped nodes in settings) — tear down and + // re-dial. Existing subscriptions live in wantedTopics and will be + // replayed after the new onopen fires. + if (this.url !== newURL && (state === WebSocket.OPEN || state === WebSocket.CONNECTING)) { + try { this.ws.close(); } catch { /* noop */ } + this.ws = null; + } + } + this.closing = false; + this.url = newURL; + try { + this.ws = new WebSocket(this.url); + } catch (e: any) { + this.fireConnectionChange(false, e?.message ?? 'ws construct failed'); + this.scheduleReconnect(); + return; + } + + this.ws.onopen = () => { + this.reconnectMs = 1000; // reset backoff + this.fireConnectionChange(true); + // Replay all wanted subscriptions. + for (const topic of this.wantedTopics) { + this.sendRaw({ op: 'subscribe', topic }); + } + }; + + this.ws.onmessage = (ev) => { + let frame: WSFrame; + try { + frame = JSON.parse(typeof ev.data === 'string' ? ev.data : ''); + } catch { + return; + } + if (frame.event === 'hello') { + this.helloInfo = { + chainId: frame.chain_id, + tipHeight: frame.tip_height, + authNonce: frame.auth_nonce, + }; + // Auto-authenticate if credentials are set. The server binds this + // connection to the signed pubkey so scoped subscriptions (addr:*, + // inbox:*) get through. On reconnect a new nonce is issued, so the + // auth dance repeats transparently. + if (this.authCreds && frame.auth_nonce) { + this.sendAuth(frame.auth_nonce); + } + } + // Dispatch to all handlers for any topic that could match this frame. + // We use a simple predicate: look at the frame to decide which topics it + // was fanned out to, then fire every matching handler. + for (const topic of this.topicsForFrame(frame)) { + const set = this.handlers.get(topic); + if (!set) continue; + for (const h of set) { + try { h(frame); } catch (e) { console.warn('[ws] handler error', e); } + } + } + }; + + this.ws.onerror = (e: any) => { + this.fireConnectionChange(false, 'ws error'); + }; + + this.ws.onclose = () => { + this.ws = null; + this.fireConnectionChange(false); + if (!this.closing) this.scheduleReconnect(); + }; + } + + disconnect(): void { + this.closing = true; + if (this.ws) { + try { this.ws.close(); } catch { /* noop */ } + this.ws = null; + } + } + + /** + * Subscribe to a topic. Returns an `off()` function that unsubscribes AND + * removes the handler. If multiple callers subscribe to the same topic, + * the server is only notified on the first and last caller. + */ + subscribe(topic: string, handler: Handler): () => void { + let set = this.handlers.get(topic); + if (!set) { + set = new Set(); + this.handlers.set(topic, set); + } + set.add(handler); + + // Notify server only on the first handler for this topic. + if (!this.wantedTopics.has(topic)) { + this.wantedTopics.add(topic); + if (this.isConnected()) { + this.sendRaw({ op: 'subscribe', topic }); + } else { + this.connect(); // lazy-connect on first subscribe + } + } + + return () => { + const s = this.handlers.get(topic); + if (!s) return; + s.delete(handler); + if (s.size === 0) { + this.handlers.delete(topic); + this.wantedTopics.delete(topic); + if (this.isConnected()) { + this.sendRaw({ op: 'unsubscribe', topic }); + } + } + }; + } + + /** Force a keepalive ping. Useful for debugging. */ + ping(): void { + this.sendRaw({ op: 'ping' }); + } + + /** + * Send a typing indicator to another user. Recipient is their X25519 pubkey + * (the one used for inbox encryption). Ephemeral — no ack, no retry; just + * fire and forget. Call on each keystroke but throttle to once per 2-3s + * at the caller side so we don't flood the WS with frames. + */ + sendTyping(recipientX25519: string): void { + if (!this.isConnected()) return; + try { + this.ws!.send(JSON.stringify({ op: 'typing', to: recipientX25519 })); + } catch { /* best-effort */ } + } + + /** + * Submit a signed transaction over the WebSocket and resolve once the + * server returns a `submit_ack`. Saves the HTTP round-trip on every tx + * and gives the UI immediate accept/reject feedback. + * + * Rejects if: + * - WS is not connected (caller should fall back to HTTP) + * - Server returns `status: "rejected"` — `reason` is surfaced as error msg + * - No ack within `timeoutMs` (default 10 s) + */ + submitTx(tx: unknown, timeoutMs = 10_000): Promise<{ id: string }> { + if (!this.isConnected()) { + return Promise.reject(new Error('WS not connected')); + } + const reqId = 's_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + + return new Promise((resolve, reject) => { + const off = this.subscribe('$system', (frame) => { + if (frame.event !== 'submit_ack' || frame.id !== reqId) return; + off(); + clearTimeout(timer); + if (frame.status === 'accepted') { + // `msg` carries the server-confirmed tx id. + resolve({ id: typeof frame.msg === 'string' ? frame.msg : '' }); + } else { + reject(new Error(frame.reason || 'submit_tx rejected')); + } + }); + const timer = setTimeout(() => { + off(); + reject(new Error('submit_tx timeout (' + timeoutMs + 'ms)')); + }, timeoutMs); + + try { + this.ws!.send(JSON.stringify({ op: 'submit_tx', tx, id: reqId })); + } catch (e: any) { + off(); + clearTimeout(timer); + reject(new Error('WS send failed: ' + (e?.message ?? 'unknown'))); + } + }); + } + + // ── internals ─────────────────────────────────────────────────────────── + + private scheduleReconnect(): void { + if (this.closing) return; + const delay = Math.min(this.reconnectMs, 30_000); + this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000); + setTimeout(() => { + if (!this.closing) this.connect(); + }, delay); + } + + private sendRaw(cmd: { op: string; topic?: string }): void { + if (!this.isConnected()) return; + try { this.ws!.send(JSON.stringify(cmd)); } catch { /* noop */ } + } + + /** + * Sign the server nonce with our Ed25519 private key and send the `auth` + * op. The server binds this connection to `authCreds.pubKey`; subsequent + * subscribe requests to `addr:` / `inbox:` are accepted. + */ + private sendAuth(nonce: string): void { + if (!this.authCreds || !this.isConnected()) return; + try { + const bytes = new TextEncoder().encode(nonce); + const sig = sign(bytes, this.authCreds.privKey); + this.ws!.send(JSON.stringify({ + op: 'auth', + pubkey: this.authCreds.pubKey, + sig, + })); + } catch (e) { + console.warn('[ws] auth send failed:', e); + } + } + + /** + * Given an incoming frame, enumerate every topic that handlers could have + * subscribed to and still be interested. This mirrors the fan-out logic in + * node/ws.go:EmitBlock / EmitTx / EmitContractLog. + */ + private topicsForFrame(frame: WSFrame): string[] { + switch (frame.event) { + case 'block': + return ['blocks']; + case 'tx': { + const d = frame.data as { from?: string; to?: string } | undefined; + const topics = ['tx']; + if (d?.from) topics.push('addr:' + d.from); + if (d?.to && d.to !== d.from) topics.push('addr:' + d.to); + return topics; + } + case 'contract_log': { + const d = frame.data as { contract_id?: string } | undefined; + const topics = ['contract_log']; + if (d?.contract_id) topics.push('contract:' + d.contract_id); + return topics; + } + case 'inbox': { + // Node fans inbox events to `inbox` + `inbox:`; + // we mirror that here so both firehose listeners and address-scoped + // subscribers see the event. + const d = frame.data as { recipient_pub?: string } | undefined; + const topics = ['inbox']; + if (d?.recipient_pub) topics.push('inbox:' + d.recipient_pub); + return topics; + } + case 'typing': { + // Server fans to `typing:` only (the recipient). + const d = frame.data as { to?: string } | undefined; + return d?.to ? ['typing:' + d.to] : []; + } + // Control-plane events — no topic fan-out; use a pseudo-topic so UI + // can listen for them via subscribe('$system', ...). + case 'hello': + case 'pong': + case 'error': + case 'subscribed': + case 'submit_ack': + case 'lag': + return ['$system']; + default: + return []; + } + } +} + +let _singleton: WSClient | null = null; + +/** + * Return the app-wide WebSocket client. Safe to call from any component; + * `.connect()` is idempotent. + * + * On first creation we register a node-URL listener so flipping the node + * in Settings tears down the existing socket and dials the new one — the + * user's active subscriptions (addr:*, inbox:*) replay automatically. + */ +export function getWSClient(): WSClient { + if (!_singleton) { + _singleton = new WSClient(); + onNodeUrlChange(() => { + // Fire and forget — connect() is idempotent and handles stale URLs. + _singleton!.connect(); + }); + } + return _singleton; +} diff --git a/client-app/metro.config.js b/client-app/metro.config.js new file mode 100644 index 0000000..f3321ba --- /dev/null +++ b/client-app/metro.config.js @@ -0,0 +1,6 @@ +const { getDefaultConfig } = require('expo/metro-config'); +const { withNativeWind } = require('nativewind/metro'); + +const config = getDefaultConfig(__dirname); + +module.exports = withNativeWind(config, { input: './global.css' }); diff --git a/client-app/nativewind-env.d.ts b/client-app/nativewind-env.d.ts new file mode 100644 index 0000000..a13e313 --- /dev/null +++ b/client-app/nativewind-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client-app/package-lock.json b/client-app/package-lock.json new file mode 100644 index 0000000..0e78df7 --- /dev/null +++ b/client-app/package-lock.json @@ -0,0 +1,10317 @@ +{ + "name": "dchain-messenger", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dchain-messenger", + "version": "1.0.0", + "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "expo": "~54.0.0", + "expo-asset": "~12.0.12", + "expo-camera": "~16.1.6", + "expo-clipboard": "~8.0.8", + "expo-constants": "~18.0.13", + "expo-crypto": "~14.1.4", + "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", + "expo-font": "~14.0.11", + "expo-linking": "~8.0.11", + "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", + "expo-splash-screen": "~31.0.13", + "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.10", + "nativewind": "^4.1.23", + "react": "19.1.0", + "react-native": "0.81.5", + "react-native-reanimated": "~3.17.0", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-worklets": "~0.8.1", + "tailwind-merge": "^2.6.0", + "tailwindcss": "^3.4.17", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@types/react": "~19.1.0", + "babel-preset-expo": "~13.0.0", + "typescript": "^5.3.3" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", + "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", + "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-decorators": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", + "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", + "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", + "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", + "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-flow": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", + "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", + "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-transform-react-display-name": "^7.28.0", + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse--for-generate-function-map": { + "name": "@babel/traverse", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@expo/cli": { + "version": "54.0.23", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", + "integrity": "sha512-km0h72SFfQCmVycH/JtPFTVy69w6Lx1cHNDmfLfQqgKFYeeHTjx7LVDP4POHCtNxFP2UeRazrygJhlh4zz498g==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.8", + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.0.8", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "~54.0.14", + "@expo/osascript": "^2.3.8", + "@expo/package-manager": "^1.9.10", + "@expo/plist": "^0.4.8", + "@expo/prebuild-config": "^54.0.8", + "@expo/schema-utils": "^0.1.8", + "@expo/spawn-async": "^1.7.2", + "@expo/ws-tunnel": "^1.0.1", + "@expo/xcpretty": "^4.3.0", + "@react-native/dev-middleware": "0.81.5", + "@urql/core": "^5.0.6", + "@urql/exchange-retry": "^1.3.0", + "accepts": "^1.3.8", + "arg": "^5.0.2", + "better-opn": "~3.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "env-editor": "^0.4.1", + "expo-server": "^1.0.5", + "freeport-async": "^2.0.0", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.1.6", + "minimatch": "^9.0.0", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^3.0.1", + "pretty-bytes": "^5.6.0", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "qrcode-terminal": "0.11.0", + "require-from-string": "^2.0.2", + "requireg": "^0.2.2", + "resolve": "^1.22.2", + "resolve-from": "^5.0.0", + "resolve.exports": "^2.0.3", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "source-map-support": "~0.5.21", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "tar": "^7.5.2", + "terminal-link": "^2.1.1", + "undici": "^6.18.2", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1" + }, + "bin": { + "expo-internal": "build/bin/cli" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/cli/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/cli/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/cli/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/cli/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/config": { + "version": "12.0.13", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", + "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "~7.10.4", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/json-file": "^10.0.8", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4", + "sucrase": "~3.35.1" + } + }, + "node_modules/@expo/config-plugins": { + "version": "54.0.4", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", + "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^54.0.10", + "@expo/json-file": "~10.0.8", + "@expo/plist": "^0.4.8", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slash": "^3.0.0", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-plugins/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config-plugins/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config-plugins/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config-plugins/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/config-types": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", + "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", + "license": "MIT" + }, + "node_modules/@expo/config/node_modules/@babel/code-frame": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@expo/config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", + "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/env": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.11.tgz", + "integrity": "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0" + } + }, + "node_modules/@expo/fingerprint": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.4.tgz", + "integrity": "sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^9.0.0", + "p-limit": "^3.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/fingerprint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/fingerprint/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/fingerprint/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/fingerprint/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.13.tgz", + "integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^55.0.4", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/image-utils/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/json-file": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.13.tgz", + "integrity": "sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/metro": { + "version": "54.2.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", + "integrity": "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==", + "license": "MIT", + "dependencies": { + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3" + } + }, + "node_modules/@expo/metro-config": { + "version": "54.0.14", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz", + "integrity": "sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8", + "@expo/json-file": "~10.0.8", + "@expo/metro": "~54.2.0", + "@expo/spawn-async": "^1.7.2", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "dotenv": "~16.4.5", + "dotenv-expand": "~11.0.6", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.29.1", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "minimatch": "^9.0.0", + "postcss": "~8.4.32", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@expo/metro-config/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/metro-config/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@expo/metro-config/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/metro-config/node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@expo/metro-config/node_modules/hermes-estree": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", + "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", + "license": "MIT" + }, + "node_modules/@expo/metro-config/node_modules/hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", + "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.29.1" + } + }, + "node_modules/@expo/metro-runtime": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", + "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", + "license": "MIT", + "dependencies": { + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@expo/osascript": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.2.tgz", + "integrity": "sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.4.tgz", + "integrity": "sha512-y9Mr4Kmpk4abAVZrNNPCdzOZr8nLLyi18p1SXr0RCVA8IfzqZX/eY4H+50a0HTmXqIsPZrQdcdb4I3ekMS9GvQ==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^10.0.13", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/plist": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", + "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.2.3", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/prebuild-config": { + "version": "54.0.8", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", + "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/config-types": "^54.0.10", + "@expo/image-utils": "^0.8.8", + "@expo/json-file": "^10.0.8", + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.1", + "resolve-from": "^5.0.0", + "semver": "^7.6.0", + "xml2js": "0.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/@expo/prebuild-config/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@expo/require-utils": { + "version": "55.0.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.4.tgz", + "integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/schema-utils": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", + "integrity": "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==", + "license": "MIT" + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", + "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/vector-icons": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", + "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", + "license": "MIT", + "peerDependencies": { + "expo-font": ">=14.0.4", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/ws-tunnel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", + "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", + "license": "MIT" + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.3.tgz", + "integrity": "sha512-wC562eD3gS6vO2tWHToFhlFnmHKfKHgF1oyvojeSkLK/ZYop1bMU+7cOMiF9Sq70CzcsLy/EMRy/uRc76QmNRw==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", + "integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.79.0-rc.4", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.79.0-rc.4.tgz", + "integrity": "sha512-vhYfoFdzz1D/bDAuGThtNia9bUpjtCo73IWfndNoKj+84SOGTtrKaZ/jOpXs5uMskpe1D4xaHuOHHFbzp/0E8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.79.0-rc.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.79.0-rc.4", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.79.0-rc.4.tgz", + "integrity": "sha512-0vyO3Ix2N0sI43w0XNJ2zzSWzX7no/JMrXnVzJEm9IhNHsuMmKaXQdRvijDZroepDINIbb6vaIWwzeWkqhiPAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.79.0-rc.4", + "babel-plugin-syntax-hermes-parser": "0.25.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.79.0-rc.4", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.79.0-rc.4.tgz", + "integrity": "sha512-62J5LVV0LBqyqSV1REin2+ciWamctlYMFyy216Gko/+aqMdoYVX/bM14pdPmqtPZRoMEgWQCgmfw+xc0yx9aPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.1.1", + "hermes-parser": "0.25.1", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz", + "integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.81.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.83.1", + "metro-config": "^0.83.1", + "metro-core": "^0.83.1", + "semver": "^7.1.3" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "*" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", + "integrity": "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz", + "integrity": "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.81.5", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.2.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^6.2.3" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz", + "integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", + "integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==", + "license": "MIT", + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", + "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", + "integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.0", + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-navigation/bottom-tabs": { + "version": "7.15.9", + "resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.9.tgz", + "integrity": "sha512-Ou28A1aZLj5wiFQ3F93aIsrI4NCwn3IJzkkjNo9KLFXsc0Yks+UqrVaFlffHFLsrbajuGRG/OQpnMA1ljayY5Q==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.14", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.2", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/core": { + "version": "7.17.2", + "resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-7.17.2.tgz", + "integrity": "sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==", + "license": "MIT", + "dependencies": { + "@react-navigation/routers": "^7.5.3", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "query-string": "^7.1.3", + "react-is": "^19.1.0", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": ">= 18.2.0" + } + }, + "node_modules/@react-navigation/elements": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-2.9.14.tgz", + "integrity": "sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4", + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@react-native-masked-view/masked-view": ">= 0.2.0", + "@react-navigation/native": "^7.2.2", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0" + }, + "peerDependenciesMeta": { + "@react-native-masked-view/masked-view": { + "optional": true + } + } + }, + "node_modules/@react-navigation/native": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.2.2.tgz", + "integrity": "sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==", + "license": "MIT", + "dependencies": { + "@react-navigation/core": "^7.17.2", + "escape-string-regexp": "^4.0.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^3.3.11", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*" + } + }, + "node_modules/@react-navigation/native-stack": { + "version": "7.14.11", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.11.tgz", + "integrity": "sha512-1ufBtJ7KbVFlQhXsYSYHqjgkmP30AzJSgW48YjWMQZ3NZGAyYe34w9Wd4KpdebQCfDClPe9maU+8crA/awa6lQ==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.14", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.2", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", + "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", + "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@urql/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", + "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.13", + "wonka": "^6.3.2" + } + }, + "node_modules/@urql/exchange-retry": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", + "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", + "license": "MIT", + "dependencies": { + "@urql/core": "^5.1.2", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^5.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.19.13", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.19.13.tgz", + "integrity": "sha512-4hHoto6xaN23LCyZgL9LJZc3olmAxd7b6jDzlZnKXAh4rRAbZRKNBJoOOdp46OBqgy+K0t0guTj5/mhA8inymQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.25.1.tgz", + "integrity": "sha512-IVNpGzboFLfXZUAwkLFcI/bnqVbwky0jP3eBno4HKtqvQJAHBLdgxiG6lQ4to0+Q/YCN3PO0od5NZwIKyY4REQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-parser": "0.25.1" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-expo": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-13.0.0.tgz", + "integrity": "sha512-4NfamKh+BKu6v0VUtZ2wFuZ9VdaDnYOC+vsAHhUF3ks1jzFLo9TwBqsrkhD129DIHfdhVJnRJah2KRCXEjcrVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.79.0-rc.4", + "babel-plugin-react-native-web": "~0.19.13", + "babel-plugin-syntax-hermes-parser": "^0.25.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "react-refresh": "^0.14.2", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "babel-plugin-react-compiler": "^19.0.0-beta-9ee70a1-20241017", + "react-compiler-runtime": "^19.0.0-beta-8a03594-20241020" + }, + "peerDependenciesMeta": { + "babel-plugin-react-compiler": { + "optional": true + }, + "react-compiler-runtime": { + "optional": true + } + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "license": "MIT", + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/better-opn/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", + "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/comment-json": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.339", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", + "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/env-editor": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", + "integrity": "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo": { + "version": "54.0.33", + "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", + "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "54.0.23", + "@expo/config": "~12.0.13", + "@expo/config-plugins": "~54.0.4", + "@expo/devtools": "0.1.8", + "@expo/fingerprint": "0.15.4", + "@expo/metro": "~54.2.0", + "@expo/metro-config": "54.0.14", + "@expo/vector-icons": "^15.0.3", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~54.0.10", + "expo-asset": "~12.0.12", + "expo-constants": "~18.0.13", + "expo-file-system": "~19.0.21", + "expo-font": "~14.0.11", + "expo-keep-awake": "~15.0.8", + "expo-modules-autolinking": "3.0.24", + "expo-modules-core": "3.0.29", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-without-unicode": "8.0.0-3" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-native": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-asset": { + "version": "12.0.12", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz", + "integrity": "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.8.8", + "expo-constants": "~18.0.12" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-camera": { + "version": "16.1.11", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-16.1.11.tgz", + "integrity": "sha512-etA5ZKoC6nPBnWWqiTmlX//zoFZ6cWQCCIdmpUHTGHAKd4qZNCkhPvBWbi8o32pDe57lix1V4+TPFgEcvPwsaA==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-clipboard": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz", + "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-constants": { + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", + "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.13", + "@expo/env": "~2.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-crypto": { + "version": "14.1.5", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-14.1.5.tgz", + "integrity": "sha512-ZXJoUMoUeiMNEoSD4itItFFz3cKrit6YJ/BR0hjuwNC+NczbV9rorvhvmeJmrU9O2cFQHhJQQR1fjQnt45Vu4Q==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-document-picker": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", + "integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "19.0.21", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", + "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "14.0.11", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", + "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-keep-awake": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", + "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-linking": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", + "integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", + "license": "MIT", + "dependencies": { + "expo-constants": "~18.0.12", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", + "integrity": "sha512-TP+6HTwhL7orDvsz2VzauyQlXJcAWyU3ANsZ7JGL4DQu8XaZv/A41ZchbtAYLfozNA2Ya1Hzmhx65hXryBMjaQ==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.1.0", + "commander": "^7.2.0", + "require-from-string": "^2.0.2", + "resolve-from": "^5.0.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-core": { + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.29.tgz", + "integrity": "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-router": { + "version": "6.0.23", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz", + "integrity": "sha512-qCxVAiCrCyu0npky6azEZ6dJDMt77OmCzEbpF6RbUTlfkaCA417LvY14SBkk0xyGruSxy/7pvJOI6tuThaUVCA==", + "license": "MIT", + "dependencies": { + "@expo/metro-runtime": "^6.1.2", + "@expo/schema-utils": "^0.1.8", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-tabs": "^1.1.12", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/native": "^7.1.8", + "@react-navigation/native-stack": "^7.3.16", + "client-only": "^0.0.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "expo-server": "^1.0.5", + "fast-deep-equal": "^3.1.3", + "invariant": "^2.2.4", + "nanoid": "^3.3.8", + "query-string": "^7.1.3", + "react-fast-compare": "^3.2.2", + "react-native-is-edge-to-edge": "^1.1.6", + "semver": "~7.6.3", + "server-only": "^0.0.1", + "sf-symbols-typescript": "^2.1.0", + "shallowequal": "^1.1.0", + "use-latest-callback": "^0.2.1", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@expo/metro-runtime": "^6.1.2", + "@react-navigation/drawer": "^7.5.0", + "@testing-library/react-native": ">= 12.0.0", + "expo": "*", + "expo-constants": "^18.0.13", + "expo-linking": "^8.0.11", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-gesture-handler": "*", + "react-native-reanimated": "*", + "react-native-safe-area-context": ">= 5.4.0", + "react-native-screens": "*", + "react-native-web": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "@react-navigation/drawer": { + "optional": true + }, + "@testing-library/react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-gesture-handler": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-server": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", + "integrity": "sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-sharing": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz", + "integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-splash-screen": { + "version": "31.0.13", + "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz", + "integrity": "sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==", + "license": "MIT", + "dependencies": { + "@expo/prebuild-config": "^54.0.8" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-status-bar": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", + "integrity": "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.2.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-web-browser": { + "version": "15.0.10", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz", + "integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/@react-native/babel-plugin-codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", + "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@react-native/codegen": "0.81.5" + }, + "engines": { + "node": ">= 20.19.4" + } + }, + "node_modules/expo/node_modules/@react-native/babel-preset": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", + "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/template": "^7.25.0", + "@react-native/babel-plugin-codegen": "0.81.5", + "babel-plugin-syntax-hermes-parser": "0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/expo/node_modules/@react-native/codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", + "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.29.1", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/expo/node_modules/babel-plugin-react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz", + "integrity": "sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==", + "license": "MIT" + }, + "node_modules/expo/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz", + "integrity": "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.29.1" + } + }, + "node_modules/expo/node_modules/babel-preset-expo": { + "version": "54.0.10", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz", + "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/preset-react": "^7.22.15", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-preset": "0.81.5", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.29.1", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/hermes-estree": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", + "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", + "license": "MIT" + }, + "node_modules/expo/node_modules/hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", + "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.29.1" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/freeport-async": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", + "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", + "integrity": "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==", + "license": "MIT", + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/metro": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz", + "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "@babel/types": "^7.25.2", + "accepts": "^1.3.7", + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.32.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-config": "0.83.3", + "metro-core": "0.83.3", + "metro-file-map": "0.83.3", + "metro-resolver": "0.83.3", + "metro-runtime": "0.83.3", + "metro-source-map": "0.83.3", + "metro-symbolicate": "0.83.3", + "metro-transform-plugins": "0.83.3", + "metro-transform-worker": "0.83.3", + "mime-types": "^2.1.27", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz", + "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.32.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/metro-cache": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", + "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-cache-key": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz", + "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-config": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz", + "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.83.3", + "metro-cache": "0.83.3", + "metro-core": "0.83.3", + "metro-runtime": "0.83.3", + "yaml": "^2.6.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-core": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz", + "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.83.3" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-file-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz", + "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz", + "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-resolver": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz", + "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-runtime": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz", + "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-source-map": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz", + "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.3", + "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.83.3", + "nullthrows": "^1.1.1", + "ob1": "0.83.3", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz", + "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.83.3", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz", + "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.3", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz", + "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/types": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "metro": "0.83.3", + "metro-babel-transformer": "0.83.3", + "metro-cache": "0.83.3", + "metro-cache-key": "0.83.3", + "metro-minify-terser": "0.83.3", + "metro-source-map": "0.83.3", + "metro-transform-plugins": "0.83.3", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", + "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", + "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.32.0" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nativewind": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/nativewind/-/nativewind-4.2.3.tgz", + "integrity": "sha512-HglF1v6A8CqBFpXWs0d3yf4qQGurrreLuyE8FTRI/VDH8b0npZa2SDG5tviTkLiBg0s5j09mQALZOjxuocgMLA==", + "license": "MIT", + "dependencies": { + "comment-json": "^4.2.5", + "debug": "^4.3.7", + "react-native-css-interop": "0.2.3" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "tailwindcss": ">3.3.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nested-error-stacks": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", + "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm-package-arg/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.83.3", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz", + "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.2.tgz", + "integrity": "sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode-terminal": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", + "integrity": "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", + "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/assets-registry": "0.81.5", + "@react-native/codegen": "0.81.5", + "@react-native/community-cli-plugin": "0.81.5", + "@react-native/gradle-plugin": "0.81.5", + "@react-native/js-polyfills": "0.81.5", + "@react-native/normalize-colors": "0.81.5", + "@react-native/virtualized-lists": "0.81.5", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-jest": "^29.7.0", + "babel-plugin-syntax-hermes-parser": "0.29.1", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "glob": "^7.1.1", + "invariant": "^2.2.4", + "jest-environment-node": "^29.7.0", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.83.1", + "metro-source-map": "^0.83.1", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.26.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0", + "ws": "^6.2.3", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@types/react": "^19.1.0", + "react": "^19.1.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-css-interop": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/react-native-css-interop/-/react-native-css-interop-0.2.3.tgz", + "integrity": "sha512-wc+JI7iUfdFBqnE18HhMTtD0q9vkhuMczToA87UdHGWwMyxdT5sCcNy+i4KInPCE855IY0Ic8kLQqecAIBWz7w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.22.15", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0", + "debug": "^4.3.7", + "lightningcss": "~1.27.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=18", + "react-native": "*", + "react-native-reanimated": ">=3.6.2", + "tailwindcss": "~3" + }, + "peerDependenciesMeta": { + "react-native-safe-area-context": { + "optional": true + }, + "react-native-svg": { + "optional": true + } + } + }, + "node_modules/react-native-css-interop/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.27.0.tgz", + "integrity": "sha512-8f7aNmS1+etYSLHht0fQApPc2kNO8qGRutifN5rVIc6Xo6ABsEbqOr758UwI7ALVbTt4x1fllKt0PYgzD9S3yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^1.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.27.0", + "lightningcss-darwin-x64": "1.27.0", + "lightningcss-freebsd-x64": "1.27.0", + "lightningcss-linux-arm-gnueabihf": "1.27.0", + "lightningcss-linux-arm64-gnu": "1.27.0", + "lightningcss-linux-arm64-musl": "1.27.0", + "lightningcss-linux-x64-gnu": "1.27.0", + "lightningcss-linux-x64-musl": "1.27.0", + "lightningcss-win32-arm64-msvc": "1.27.0", + "lightningcss-win32-x64-msvc": "1.27.0" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-darwin-arm64": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.27.0.tgz", + "integrity": "sha512-Gl/lqIXY+d+ySmMbgDf0pgaWSqrWYxVHoc88q+Vhf2YNzZ8DwoRzGt5NZDVqqIW5ScpSnmmjcgXP87Dn2ylSSQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-darwin-x64": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.27.0.tgz", + "integrity": "sha512-0+mZa54IlcNAoQS9E0+niovhyjjQWEMrwW0p2sSdLRhLDc8LMQ/b67z7+B5q4VmjYCMSfnFi3djAAQFIDuj/Tg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-freebsd-x64": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.27.0.tgz", + "integrity": "sha512-n1sEf85fePoU2aDN2PzYjoI8gbBqnmLGEhKq7q0DKLj0UTVmOTwDC7PtLcy/zFxzASTSBlVQYJUhwIStQMIpRA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.27.0.tgz", + "integrity": "sha512-MUMRmtdRkOkd5z3h986HOuNBD1c2lq2BSQA1Jg88d9I7bmPGx08bwGcnB75dvr17CwxjxD6XPi3Qh8ArmKFqCA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.27.0.tgz", + "integrity": "sha512-cPsxo1QEWq2sfKkSq2Bq5feQDHdUEwgtA9KaB27J5AX22+l4l0ptgjMZZtYtUnteBofjee+0oW1wQ1guv04a7A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.27.0.tgz", + "integrity": "sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.27.0.tgz", + "integrity": "sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-linux-x64-musl": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.27.0.tgz", + "integrity": "sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.27.0.tgz", + "integrity": "sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.27.0.tgz", + "integrity": "sha512-/OJLj94Zm/waZShL8nB5jsNj3CfNATLCTyFxZyouilfTmSoLDX7VlVAmhPHoZWVFp4vdmoiEbPEYC8HID3m6yw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/react-native-css-interop/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-reanimated": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz", + "integrity": "sha512-SxBK7wQfJ4UoWoJqQnmIC7ZjuNgVb9rcY5Xc67upXAFKftWg0rnkknTw6vgwnjRcvYThrjzUVti66XoZdDJGtw==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.0.0-0", + "@babel/plugin-transform-class-properties": "^7.0.0-0", + "@babel/plugin-transform-classes": "^7.0.0-0", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.0.0-0", + "@babel/plugin-transform-optional-chaining": "^7.0.0-0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", + "@babel/plugin-transform-template-literals": "^7.0.0-0", + "@babel/plugin-transform-unicode-regex": "^7.0.0-0", + "@babel/preset-typescript": "^7.16.7", + "convert-source-map": "^2.0.0", + "invariant": "^2.2.4", + "react-native-is-edge-to-edge": "1.1.7" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0", + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz", + "integrity": "sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", + "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz", + "integrity": "sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "react-native-is-edge-to-edge": "^1.2.1", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.8.1.tgz", + "integrity": "sha512-oWP/lStsAHU6oYCaWDXrda/wOHVdhusQJz1e6x9gPnXdFf4ndNDAOtWCmk2zGrAnlapfyA3rM6PCQq94mPg9cw==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.4", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "convert-source-map": "^2.0.0", + "semver": "^7.7.3" + }, + "peerDependencies": { + "@babel/core": "*", + "@react-native/metro-config": "*", + "react": "*", + "react-native": "0.81 - 0.85" + } + }, + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/@react-native/codegen": { + "version": "0.81.5", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", + "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.25.3", + "glob": "^7.1.1", + "hermes-parser": "0.29.1", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "yargs": "^17.6.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz", + "integrity": "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.29.1" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/hermes-estree": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", + "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/hermes-parser": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", + "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.29.1" + } + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", + "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requireg": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz", + "integrity": "sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==", + "dependencies": { + "nested-error-stacks": "~2.0.1", + "rc": "~1.2.7", + "resolve": "~1.7.1" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/requireg/node_modules/resolve": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", + "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", + "license": "MIT", + "dependencies": { + "path-parse": "^1.0.5" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT" + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/tweetnacl-util": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", + "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==", + "license": "Unlicense" + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url-without-unicode": { + "version": "8.0.0-3", + "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", + "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==", + "license": "MIT", + "dependencies": { + "buffer": "^5.4.3", + "punycode": "^2.1.1", + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wonka": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/client-app/package.json b/client-app/package.json new file mode 100644 index 0000000..d3ff6ab --- /dev/null +++ b/client-app/package.json @@ -0,0 +1,51 @@ +{ + "name": "dchain-messenger", + "version": "1.0.0", + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "lint": "eslint ." + }, + "dependencies": { + "@react-native-async-storage/async-storage": "2.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "expo": "~54.0.0", + "expo-asset": "~12.0.12", + "expo-camera": "~16.1.6", + "expo-crypto": "~14.1.4", + "expo-clipboard": "~8.0.8", + "expo-constants": "~18.0.13", + "expo-document-picker": "~14.0.8", + "expo-file-system": "~19.0.21", + "expo-font": "~14.0.11", + "expo-linking": "~8.0.11", + "expo-router": "~6.0.23", + "expo-secure-store": "~15.0.8", + "expo-sharing": "~14.0.8", + "expo-splash-screen": "~31.0.13", + "expo-status-bar": "~3.0.9", + "expo-web-browser": "~15.0.10", + "nativewind": "^4.1.23", + "react": "19.1.0", + "react-native": "0.81.5", + "react-native-reanimated": "~3.17.0", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-worklets": "~0.8.1", + "tailwind-merge": "^2.6.0", + "tailwindcss": "^3.4.17", + "tweetnacl": "^1.0.3", + "tweetnacl-util": "^0.15.1", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@types/react": "~19.1.0", + "babel-preset-expo": "~13.0.0", + "typescript": "^5.3.3" + } +} diff --git a/client-app/tailwind.config.js b/client-app/tailwind.config.js new file mode 100644 index 0000000..35bef05 --- /dev/null +++ b/client-app/tailwind.config.js @@ -0,0 +1,28 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './app/**/*.{js,jsx,ts,tsx}', + './components/**/*.{js,jsx,ts,tsx}', + './hooks/**/*.{js,jsx,ts,tsx}', + './lib/**/*.{js,jsx,ts,tsx}', + ], + presets: [require('nativewind/preset')], + theme: { + extend: { + colors: { + // DChain brand — deep navy + teal accent + background: '#0d1117', + surface: '#161b22', + surfaceHigh: '#21262d', + border: '#30363d', + primary: '#2563eb', + primaryFg: '#ffffff', + accent: '#22d3ee', + muted: '#8b949e', + destructive: '#f85149', + success: '#3fb950', + }, + }, + }, + plugins: [], +}; diff --git a/client-app/tsconfig.json b/client-app/tsconfig.json new file mode 100644 index 0000000..140c044 --- /dev/null +++ b/client-app/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..442b016 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,1564 @@ +// cmd/client — CLI client for interacting with the chain. +// +// Commands: +// +// client keygen --out +// client register --key --nick +// client balance --key --db +// client transfer --key --to --amount +// client info --db +// client request-contact --key --to --fee [--intro "text"] --node +// client accept-contact --key --from --node +// client block-contact --key --from --node +// client contacts --key --node +// client send-msg --key --to --msg "text" --node +// client inbox --key --node +// client deploy-contract --key --wasm --abi --node +// client call-contract --key --contract --method [--args '["v"]'] [--gas N] --node +// client stake --key --amount --node +// client unstake --key --node +// client wait-tx --id [--timeout ] --node +// client issue-token --key --name --symbol [--decimals N] --supply --node +// client transfer-token --key --token --to --amount --node +// client burn-token --key --token --amount --node +// client token-balance --token [--address ] --node +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "go-blockchain/blockchain" + "go-blockchain/economy" + "go-blockchain/identity" + "go-blockchain/node/version" + "go-blockchain/relay" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + cmd := os.Args[1] + args := os.Args[2:] + + switch cmd { + case "keygen": + cmdKeygen(args) + case "register": + cmdRegister(args) + case "balance": + cmdBalance(args) + case "transfer": + cmdTransfer(args) + case "info": + cmdInfo(args) + case "request-contact": + cmdRequestContact(args) + case "accept-contact": + cmdAcceptContact(args) + case "block-contact": + cmdBlockContact(args) + case "contacts": + cmdContacts(args) + case "add-validator": + cmdAddValidator(args) + case "admit-sign": + cmdAdmitSign(args) + case "remove-validator": + cmdRemoveValidator(args) + case "send-msg": + cmdSendMsg(args) + case "inbox": + cmdInbox(args) + case "deploy-contract": + cmdDeployContract(args) + case "call-contract": + cmdCallContract(args) + case "stake": + cmdStake(args) + case "unstake": + cmdUnstake(args) + case "wait-tx": + cmdWaitTx(args) + case "issue-token": + cmdIssueToken(args) + case "transfer-token": + cmdTransferToken(args) + case "burn-token": + cmdBurnToken(args) + case "token-balance": + cmdTokenBalance(args) + case "mint-nft": + cmdMintNFT(args) + case "transfer-nft": + cmdTransferNFT(args) + case "burn-nft": + cmdBurnNFT(args) + case "nft-info": + cmdNFTInfo(args) + case "version", "--version", "-v": + fmt.Println(version.String()) + default: + usage() + os.Exit(1) + } +} + +func usage() { + fmt.Println(`Usage: client [flags] + +Commands: + keygen --out Generate a new identity + register --key --nick Build a REGISTER_KEY transaction + balance --key --db Show token balance + transfer --key --to --amount Send tokens via node API + [--memo "text"] [--node http://localhost:8080] + info --db Show chain info + + request-contact --key --to Send a paid contact request (ICQ-style) + --fee [--intro "text"] + [--node http://localhost:8080] + accept-contact --key --from Accept a contact request + [--node http://localhost:8080] + block-contact --key --from Block a contact request + [--node http://localhost:8080] + contacts --key List incoming contact requests + [--node http://localhost:8080] + + add-validator --key --target Add a validator (caller must be a validator) + [--reason "text"] [--node http://localhost:8080] + remove-validator --key --target Remove a validator (or self-remove) + [--reason "text"] [--node http://localhost:8080] + + send-msg --key --to Send an encrypted message + --msg "text" [--node http://localhost:8080] + inbox --key Read and decrypt inbox messages + [--node http://localhost:8080] [--limit N] + + deploy-contract --key --wasm --abi Deploy a WASM smart contract + [--node http://localhost:8080] + call-contract --key --contract Call a smart contract method + --method [--args '["val",42]'] [--gas N] + [--node http://localhost:8080] + + stake --key --amount Lock tokens as validator stake + [--node http://localhost:8080] + unstake --key Release all staked tokens + [--node http://localhost:8080] + + wait-tx --id [--timeout ] Wait for a transaction to be confirmed + [--node http://localhost:8080] + + issue-token --key --name Issue a new fungible token + --symbol [--decimals N] + --supply + [--node http://localhost:8080] + transfer-token --key --token Transfer fungible tokens + --to --amount + [--node http://localhost:8080] + burn-token --key --token Burn (destroy) fungible tokens + --amount + [--node http://localhost:8080] + token-balance --token [--address ] Query token balance + [--node http://localhost:8080] + + mint-nft --key --name Mint a new NFT + [--desc "text"] [--uri ] [--attrs '{"k":"v"}'] + [--node http://localhost:8080] + transfer-nft --key --nft --to Transfer NFT ownership + [--node http://localhost:8080] + burn-nft --key --nft Burn (destroy) an NFT + [--node http://localhost:8080] + nft-info --nft [--owner ] Query NFT info or owner's NFTs + [--node http://localhost:8080]`) +} + +// --- keygen --- + +func cmdKeygen(args []string) { + fs := flag.NewFlagSet("keygen", flag.ExitOnError) + out := fs.String("out", "key.json", "output file") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + id, err := identity.Generate() + if err != nil { + log.Fatal(err) + } + saveKey(*out, id) + fmt.Printf("New identity generated:\n pub_key: %s\n x25519_pub: %s\n saved to: %s\n", + id.PubKeyHex(), id.X25519PubHex(), *out) +} + +// --- register --- + +func cmdRegister(args []string) { + fs := flag.NewFlagSet("register", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + nick := fs.String("nick", "", "nickname") + difficulty := fs.Int("pow", 4, "PoW difficulty (hex nibbles)") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + dryRun := fs.Bool("dry-run", false, "print TX JSON without broadcasting") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + id := loadKey(*keyFile) + fmt.Printf("Mining registration PoW (difficulty %d)...\n", *difficulty) + tx, err := identity.RegisterTx(id, *nick, *difficulty) + if err != nil { + log.Fatal(err) + } + + if *dryRun { + data, _ := json.MarshalIndent(tx, "", " ") + fmt.Println(string(data)) + return + } + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("Registration submitted: %s\n", result) +} + +// --- balance --- + +func cmdBalance(args []string) { + fs := flag.NewFlagSet("balance", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + dbPath := fs.String("db", "./chaindata", "chain DB path") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + id := loadKey(*keyFile) + chain, err := blockchain.NewChain(*dbPath) + if err != nil { + log.Fatal(err) + } + defer chain.Close() + + bal, err := chain.Balance(id.PubKeyHex()) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Balance of %s:\n %s (%d µT)\n", + id.PubKeyHex(), economy.FormatTokens(bal), bal) +} + +// --- transfer --- + +func cmdTransfer(args []string) { + fs := flag.NewFlagSet("transfer", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "sender identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + to := fs.String("to", "", "recipient: hex pubkey, DC address, or @username") + amountStr := fs.String("amount", "0", "amount in tokens (e.g. 1.5)") + memo := fs.String("memo", "", "optional transfer memo") + dryRun := fs.Bool("dry-run", false, "print TX JSON without broadcasting") + registry := fs.String("registry", "", "username_registry contract ID for @username resolution") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + if *to == "" { + log.Fatal("--to is required") + } + + toResolved := strings.TrimPrefix(*to, "@") + recipient, err := resolveRecipientPubKey(*nodeURL, toResolved, *registry) + if err != nil { + log.Fatalf("resolve recipient: %v", err) + } + + id := loadKey(*keyFile) + amount, err := parseTokenAmount(*amountStr) + if err != nil { + log.Fatalf("invalid amount: %v", err) + } + + tx := buildTransferTx(id, recipient, amount, *memo) + + if *dryRun { + data, _ := json.MarshalIndent(tx, "", " ") + fmt.Println(string(data)) + return + } + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("Transaction submitted: %s\n", result) +} + +// --- info --- + +func cmdInfo(args []string) { + fs := flag.NewFlagSet("info", flag.ExitOnError) + dbPath := fs.String("db", "./chaindata", "chain DB path") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + chain, err := blockchain.NewChain(*dbPath) + if err != nil { + log.Fatal(err) + } + defer chain.Close() + + tip := chain.Tip() + if tip == nil { + fmt.Println("Chain is empty (no genesis block yet)") + return + } + fmt.Printf("Chain info:\n") + fmt.Printf(" Height: %d\n", tip.Index) + fmt.Printf(" Tip hash: %s\n", tip.HashHex()) + fmt.Printf(" Tip time: %s\n", tip.Timestamp.Format(time.RFC3339)) + fmt.Printf(" Validator: %s\n", tip.Validator) + fmt.Printf(" Tx count: %d\n", len(tip.Transactions)) +} + +// --- request-contact --- + +func cmdRequestContact(args []string) { + fs := flag.NewFlagSet("request-contact", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "sender identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + to := fs.String("to", "", "recipient pub key or DC address") + feeStr := fs.String("fee", "0.005", "contact fee in tokens (min 0.005)") + intro := fs.String("intro", "", "optional intro message (≤ 280 chars)") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + if *to == "" { + log.Fatal("--to is required") + } + if len(*intro) > 280 { + log.Fatal("--intro must be ≤ 280 characters") + } + + recipient, err := resolveRecipientPubKey(*nodeURL, *to) + if err != nil { + log.Fatalf("resolve recipient: %v", err) + } + + id := loadKey(*keyFile) + feeUT, err := parseTokenAmount(*feeStr) + if err != nil { + log.Fatalf("invalid fee: %v", err) + } + if feeUT < blockchain.MinContactFee { + log.Fatalf("fee must be at least %s (%d µT)", economy.FormatTokens(blockchain.MinContactFee), blockchain.MinContactFee) + } + + payload, _ := json.Marshal(blockchain.ContactRequestPayload{Intro: *intro}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventContactRequest, + From: id.PubKeyHex(), + To: recipient, + Amount: feeUT, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("Contact request sent to %s\n fee: %s\n result: %s\n", + recipient, economy.FormatTokens(feeUT), result) +} + +// --- accept-contact --- + +func cmdAcceptContact(args []string) { + fs := flag.NewFlagSet("accept-contact", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + from := fs.String("from", "", "requester pub key") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + if *from == "" { + log.Fatal("--from is required") + } + + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.AcceptContactPayload{}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventAcceptContact, + From: id.PubKeyHex(), + To: *from, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("Accepted contact request from %s\n result: %s\n", *from, result) +} + +// --- block-contact --- + +func cmdBlockContact(args []string) { + fs := flag.NewFlagSet("block-contact", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + from := fs.String("from", "", "requester pub key to block") + reason := fs.String("reason", "", "optional block reason") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + if *from == "" { + log.Fatal("--from is required") + } + + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.BlockContactPayload{Reason: *reason}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventBlockContact, + From: id.PubKeyHex(), + To: *from, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("Blocked contact from %s\n result: %s\n", *from, result) +} + +// --- contacts --- + +func cmdContacts(args []string) { + fs := flag.NewFlagSet("contacts", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + id := loadKey(*keyFile) + + resp, err := http.Get(*nodeURL + "/relay/contacts?pub=" + id.PubKeyHex()) + if err != nil { + log.Fatalf("fetch contacts: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + log.Fatalf("node returned %d: %s", resp.StatusCode, body) + } + + var result struct { + Count int `json:"count"` + Contacts []blockchain.ContactInfo `json:"contacts"` + } + if err := json.Unmarshal(body, &result); err != nil { + log.Fatalf("parse response: %v", err) + } + + if result.Count == 0 { + fmt.Println("No contact requests.") + return + } + fmt.Printf("Contact requests (%d):\n", result.Count) + for _, c := range result.Contacts { + ts := time.Unix(c.CreatedAt, 0).UTC().Format(time.RFC3339) + fmt.Printf(" from: %s\n addr: %s\n status: %s\n fee: %s\n intro: %q\n tx: %s\n at: %s\n\n", + c.RequesterPub, c.RequesterAddr, c.Status, + economy.FormatTokens(c.FeeUT), c.Intro, c.TxID, ts) + } +} + +// --- add-validator --- + +func cmdAddValidator(args []string) { + fs := flag.NewFlagSet("add-validator", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "caller identity file (must already be a validator)") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + target := fs.String("target", "", "pub key of the new validator to add") + reason := fs.String("reason", "", "optional reason") + // Multi-sig: on a chain with >1 validators the chain requires ⌈2/3⌉ + // approvals. Use `client admit-sign --target X` on each other validator + // to get their signature, then pass them here as `pubkey:sig_hex` pairs. + cosigsFlag := fs.String("cosigs", "", + "comma-separated cosignatures from other validators, each `pubkey:signature_hex`. "+ + "Required when current validator set has more than 1 member.") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *target == "" { + log.Fatal("--target is required") + } + + cosigs, err := parseCoSigs(*cosigsFlag) + if err != nil { + log.Fatalf("--cosigs: %v", err) + } + + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.AddValidatorPayload{ + Reason: *reason, + CoSignatures: cosigs, + }) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventAddValidator, + From: id.PubKeyHex(), + To: *target, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("ADD_VALIDATOR submitted: added %s (cosigs=%d)\n result: %s\n", + *target, len(cosigs), result) +} + +// cmdAdmitSign produces a signature that a current validator hands over +// off-chain to the operator assembling the ADD_VALIDATOR tx. Prints +// +// : +// +// ready to drop into `--cosigs`. The signer never broadcasts anything +// themselves — assembly happens at the submitter. +func cmdAdmitSign(args []string) { + fs := flag.NewFlagSet("admit-sign", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "your validator key file") + target := fs.String("target", "", "candidate pubkey you are approving") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *target == "" { + log.Fatal("--target is required") + } + if len(*target) != 64 { + log.Fatalf("--target must be a 64-char hex pubkey, got %d chars", len(*target)) + } + id := loadKey(*keyFile) + sig := ed25519.Sign(id.PrivKey, blockchain.AdmitDigest(*target)) + fmt.Printf("%s:%s\n", id.PubKeyHex(), hex.EncodeToString(sig)) +} + +// parseCoSigs decodes the `--cosigs pub1:sig1,pub2:sig2,...` format into +// the payload-friendly slice. Each entry validates: pubkey is 64 hex chars, +// signature decodes cleanly. +func parseCoSigs(raw string) ([]blockchain.ValidatorCoSig, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var out []blockchain.ValidatorCoSig + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + parts := strings.SplitN(entry, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("bad cosig %q: expected pubkey:sig_hex", entry) + } + pub := strings.TrimSpace(parts[0]) + if len(pub) != 64 { + return nil, fmt.Errorf("bad cosig pubkey %q: expected 64 hex chars", pub) + } + sig, err := hex.DecodeString(strings.TrimSpace(parts[1])) + if err != nil || len(sig) != 64 { + return nil, fmt.Errorf("bad cosig signature for %s: %w", pub, err) + } + out = append(out, blockchain.ValidatorCoSig{PubKey: pub, Signature: sig}) + } + return out, nil +} + +// --- remove-validator --- + +func cmdRemoveValidator(args []string) { + fs := flag.NewFlagSet("remove-validator", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "caller identity file (must be an active validator)") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + target := fs.String("target", "", "pub key of the validator to remove (omit for self-removal)") + reason := fs.String("reason", "", "optional reason") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + id := loadKey(*keyFile) + targetPub := *target + if targetPub == "" { + targetPub = id.PubKeyHex() // self-removal + } + + payload, _ := json.Marshal(blockchain.RemoveValidatorPayload{Reason: *reason}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventRemoveValidator, + From: id.PubKeyHex(), + To: targetPub, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("REMOVE_VALIDATOR submitted: removed %s\n result: %s\n", targetPub, result) +} + +// --- send-msg --- + +func cmdSendMsg(args []string) { + fs := flag.NewFlagSet("send-msg", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "sender identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + to := fs.String("to", "", "recipient: hex pubkey, DC address, or @username") + msg := fs.String("msg", "", "plaintext message to send") + registry := fs.String("registry", "", "username_registry contract ID for @username resolution") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + if *to == "" { + log.Fatal("--to is required") + } + if *msg == "" { + log.Fatal("--msg is required") + } + + // Strip leading @ from username if present + toResolved := strings.TrimPrefix(*to, "@") + + // Resolve recipient Ed25519 pub key and get their X25519 pub key + recipientPub, err := resolveRecipientPubKey(*nodeURL, toResolved, *registry) + if err != nil { + log.Fatalf("resolve recipient: %v", err) + } + + info, err := fetchIdentityInfo(*nodeURL, recipientPub) + if err != nil { + log.Fatalf("fetch identity: %v", err) + } + if info.X25519Pub == "" { + log.Fatalf("recipient %s has not published an X25519 key (not registered)", recipientPub) + } + + x25519Bytes, err := hex.DecodeString(info.X25519Pub) + if err != nil || len(x25519Bytes) != 32 { + log.Fatalf("invalid x25519 pub key from node") + } + var recipX25519 [32]byte + copy(recipX25519[:], x25519Bytes) + + // Load sender identity and build relay KeyPair + id := loadKey(*keyFile) + senderKP := &relay.KeyPair{Pub: id.X25519Pub, Priv: id.X25519Priv} + + // Seal the message + env, err := relay.Seal(senderKP, id, recipX25519, []byte(*msg), 0, time.Now().Unix()) + if err != nil { + log.Fatalf("seal message: %v", err) + } + + // Broadcast via node + type broadcastReq struct { + Envelope *relay.Envelope `json:"envelope"` + } + data, _ := json.Marshal(broadcastReq{Envelope: env}) + resp, err := http.Post(*nodeURL+"/relay/broadcast", "application/json", bytes.NewReader(data)) + if err != nil { + log.Fatalf("broadcast: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + log.Fatalf("node returned %d: %s", resp.StatusCode, body) + } + fmt.Printf("Message sent!\n envelope id: %s\n to: %s\n", env.ID, recipientPub) +} + +// --- inbox --- + +func cmdInbox(args []string) { + fs := flag.NewFlagSet("inbox", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "recipient identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + limit := fs.Int("limit", 20, "max messages to fetch") + deleteAfter := fs.Bool("delete", false, "delete messages after reading") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + id := loadKey(*keyFile) + kp := &relay.KeyPair{Pub: id.X25519Pub, Priv: id.X25519Priv} + + url := fmt.Sprintf("%s/relay/inbox?pub=%s&limit=%d", *nodeURL, id.X25519PubHex(), *limit) + resp, err := http.Get(url) + if err != nil { + log.Fatalf("fetch inbox: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + log.Fatalf("node returned %d: %s", resp.StatusCode, body) + } + + var result struct { + Count int `json:"count"` + HasMore bool `json:"has_more"` + Items []struct { + ID string `json:"id"` + SenderPub string `json:"sender_pub"` + SentAtHuman string `json:"sent_at_human"` + Nonce []byte `json:"nonce"` + Ciphertext []byte `json:"ciphertext"` + } `json:"items"` + } + if err := json.Unmarshal(body, &result); err != nil { + log.Fatalf("parse response: %v", err) + } + + if result.Count == 0 { + fmt.Println("Inbox is empty.") + return + } + + fmt.Printf("Inbox (%d messages%s):\n\n", result.Count, func() string { + if result.HasMore { + return ", more available" + } + return "" + }()) + + decrypted := 0 + for _, item := range result.Items { + env := &relay.Envelope{ + ID: item.ID, + SenderPub: item.SenderPub, + RecipientPub: id.X25519PubHex(), + Nonce: item.Nonce, + Ciphertext: item.Ciphertext, + } + msg, err := relay.Open(kp, env) + if err != nil { + fmt.Printf(" [%s] from %s at %s\n (could not decrypt: %v)\n\n", + item.ID, item.SenderPub, item.SentAtHuman, err) + continue + } + decrypted++ + fmt.Printf(" [%s]\n from: %s\n at: %s\n msg: %s\n\n", + item.ID, item.SenderPub, item.SentAtHuman, string(msg)) + + if *deleteAfter { + delURL := fmt.Sprintf("%s/relay/inbox/%s?pub=%s", *nodeURL, item.ID, id.X25519PubHex()) + req, _ := http.NewRequest(http.MethodDelete, delURL, nil) + delResp, err := http.DefaultClient.Do(req) + if err == nil { + delResp.Body.Close() + } + } + } + fmt.Printf("Decrypted %d/%d messages.\n", decrypted, result.Count) +} + +// --- deploy-contract --- + +func cmdDeployContract(args []string) { + fs := flag.NewFlagSet("deploy-contract", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "deployer identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + wasmFile := fs.String("wasm", "", "path to .wasm binary") + abiFile := fs.String("abi", "", "path to ABI JSON file") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *wasmFile == "" { + log.Fatal("--wasm is required") + } + if *abiFile == "" { + log.Fatal("--abi is required") + } + + wasmBytes, err := os.ReadFile(*wasmFile) + if err != nil { + log.Fatalf("read wasm: %v", err) + } + abiBytes, err := os.ReadFile(*abiFile) + if err != nil { + log.Fatalf("read abi: %v", err) + } + + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.DeployContractPayload{ + WASMBase64: base64.StdEncoding.EncodeToString(wasmBytes), + ABIJson: string(abiBytes), + }) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventDeployContract, + From: id.PubKeyHex(), + Fee: blockchain.MinDeployFee, + Payload: payload, + Memo: "Deploy contract", + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + + // Compute contract_id locally (deterministic: sha256(deployerPub || wasmBytes)[:16]). + h := sha256.New() + h.Write([]byte(id.PubKeyHex())) + h.Write(wasmBytes) + contractID := hex.EncodeToString(h.Sum(nil)[:16]) + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("DEPLOY_CONTRACT submitted\n contract_id: %s\n tx: %s\n", contractID, result) +} + +// --- call-contract --- + +func cmdCallContract(args []string) { + fs := flag.NewFlagSet("call-contract", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "caller identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + contractID := fs.String("contract", "", "contract ID (hex)") + method := fs.String("method", "", "method name to call") + argsFlag := fs.String("args", "", `JSON array of arguments, e.g. '["alice"]' or '[42]'`) + gas := fs.Uint64("gas", 1_000_000, "gas limit") + // --amount is the payment attached to the call, visible as tx.Amount. + // Contracts that require payment (e.g. username_registry.register costs + // 10 000 µT) enforce exact values; unused for read-only methods. + amount := fs.Uint64("amount", 0, "µT to attach to the call (for payable methods)") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *contractID == "" { + log.Fatal("--contract is required") + } + if *method == "" { + log.Fatal("--method is required") + } + // Validate --args is a JSON array if provided. + argsJSON := "" + if *argsFlag != "" { + var check []interface{} + if err := json.Unmarshal([]byte(*argsFlag), &check); err != nil { + log.Fatalf("--args must be a JSON array: %v", err) + } + argsJSON = *argsFlag + } + + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.CallContractPayload{ + ContractID: *contractID, + Method: *method, + ArgsJSON: argsJSON, + GasLimit: *gas, + }) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventCallContract, + From: id.PubKeyHex(), + Amount: *amount, // paid to contract via tx.Amount + Fee: blockchain.MinFee, + Payload: payload, + Memo: fmt.Sprintf("Call %s.%s", (*contractID)[:min(8, len(*contractID))], *method), + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit tx: %v", err) + } + fmt.Printf("CALL_CONTRACT submitted\n contract: %s\n method: %s\n tx: %s\n", + *contractID, *method, result) +} + +// --- helpers --- + +type keyJSON struct { + PubKey string `json:"pub_key"` + PrivKey string `json:"priv_key"` + X25519Pub string `json:"x25519_pub,omitempty"` + X25519Priv string `json:"x25519_priv,omitempty"` +} + +func saveKey(path string, id *identity.Identity) { + kj := keyJSON{ + PubKey: id.PubKeyHex(), + PrivKey: id.PrivKeyHex(), + X25519Pub: id.X25519PubHex(), + X25519Priv: id.X25519PrivHex(), + } + data, _ := json.MarshalIndent(kj, "", " ") + if err := os.WriteFile(path, data, 0600); err != nil { + log.Fatalf("save key: %v", err) + } +} + +func loadKey(path string) *identity.Identity { + data, err := os.ReadFile(path) + if err != nil { + log.Fatalf("read key file %s: %v", path, err) + } + var kj keyJSON + if err := json.Unmarshal(data, &kj); err != nil { + log.Fatalf("parse key file: %v", err) + } + id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv) + if err != nil { + log.Fatalf("load identity: %v", err) + } + // If X25519 keys were missing in file (old format), backfill and re-save. + if kj.X25519Pub == "" { + saveKey(path, id) + } + return id +} + +func resolveRecipientPubKey(nodeURL, input string, registryID ...string) (string, error) { + // DC address (26-char Base58Check starting with "DC") + if len(input) == 26 && input[:2] == "DC" { + resp, err := http.Get(nodeURL + "/api/address/" + input) + if err != nil { + return "", err + } + defer resp.Body.Close() + var result struct { + PubKey string `json:"pub_key"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + if result.Error != "" { + return "", fmt.Errorf("%s", result.Error) + } + fmt.Printf("Resolved %s → %s\n", input, result.PubKey) + return result.PubKey, nil + } + // Already a 64-char hex pubkey + if isHexPubKey(input) { + return input, nil + } + // Try username_registry resolution + if len(registryID) > 0 && registryID[0] != "" { + pub, err := resolveViaRegistry(nodeURL, registryID[0], input) + if err != nil { + return "", fmt.Errorf("resolve username %q: %w", input, err) + } + return pub, nil + } + // Return as-is (caller's problem) + return input, nil +} + +// isHexPubKey reports whether s looks like a 64-char hex Ed25519 public key. +func isHexPubKey(s string) bool { + if len(s) != 64 { + return false + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +// resolveViaRegistry looks up a username in the username_registry contract. +// The registry stores state["name:"] = raw bytes of the owner pubkey. +func resolveViaRegistry(nodeURL, registryID, username string) (string, error) { + url := nodeURL + "/api/contracts/" + registryID + "/state/name:" + username + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("registry lookup failed (%d): %s", resp.StatusCode, body) + } + var result struct { + ValueHex string `json:"value_hex"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parse registry response: %w", err) + } + if result.Error != "" || result.ValueHex == "" { + return "", fmt.Errorf("username %q not registered", username) + } + // value_hex is the hex-encoding of the raw pubkey bytes stored in state. + // The pubkey was stored as a plain ASCII hex string, so decode hex → string. + pubBytes, err := hex.DecodeString(result.ValueHex) + if err != nil { + return "", fmt.Errorf("decode registry value: %w", err) + } + pubkey := string(pubBytes) + fmt.Printf("Resolved @%s → %s...\n", username, pubkey[:min(8, len(pubkey))]) + return pubkey, nil +} + +func fetchIdentityInfo(nodeURL, pubKey string) (*blockchain.IdentityInfo, error) { + resp, err := http.Get(nodeURL + "/api/identity/" + pubKey) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("node returned %d: %s", resp.StatusCode, body) + } + var info blockchain.IdentityInfo + if err := json.Unmarshal(body, &info); err != nil { + return nil, err + } + return &info, nil +} + +func postTx(nodeURL string, tx *blockchain.Transaction) (string, error) { + data, err := json.Marshal(tx) + if err != nil { + return "", err + } + resp, err := http.Post(nodeURL+"/api/tx", "application/json", bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("connect to node: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("node returned %d: %s", resp.StatusCode, body) + } + return string(body), nil +} + +func parseTokenAmount(s string) (uint64, error) { + if n, err := strconv.ParseUint(s, 10, 64); err == nil { + return n * blockchain.Token, nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, err + } + return uint64(f * float64(blockchain.Token)), nil +} + +func buildTransferTx(id *identity.Identity, to string, amount uint64, memo string) *blockchain.Transaction { + payload, _ := json.Marshal(blockchain.TransferPayload{Memo: memo}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventTransfer, + From: id.PubKeyHex(), + To: to, + Amount: amount, + Fee: blockchain.MinFee, + Memo: memo, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + return tx +} + +// txSignBytes returns canonical bytes for transaction signing. +// Delegates to the exported identity.TxSignBytes so both the client CLI and +// the node use a single authoritative implementation. +func txSignBytes(tx *blockchain.Transaction) []byte { + return identity.TxSignBytes(tx) +} + +// --- stake --- + +func cmdStake(args []string) { + fs := flag.NewFlagSet("stake", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + amountStr := fs.String("amount", "", "amount to stake in T (e.g. 1000)") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *amountStr == "" { + log.Fatal("--amount is required") + } + amount, err := parseTokenAmount(*amountStr) + if err != nil { + log.Fatalf("invalid amount: %v", err) + } + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.StakePayload{}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventStake, + From: id.PubKeyHex(), + Amount: amount, + Fee: blockchain.MinFee, + Memo: "stake", + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit stake: %v", err) + } + fmt.Printf("Stake submitted: %s\ntx_id: %s\n", result, tx.ID) +} + +// --- unstake --- + +func cmdUnstake(args []string) { + fs := flag.NewFlagSet("unstake", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.UnstakePayload{}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventUnstake, + From: id.PubKeyHex(), + Fee: blockchain.MinFee, + Memo: "unstake", + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit unstake: %v", err) + } + fmt.Printf("Unstake submitted: %s\ntx_id: %s\n", result, tx.ID) +} + +// --- wait-tx --- + +func cmdWaitTx(args []string) { + fs := flag.NewFlagSet("wait-tx", flag.ExitOnError) + txID := fs.String("id", "", "transaction ID to wait for") + timeout := fs.Int("timeout", 60, "timeout in seconds") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *txID == "" { + log.Fatal("--id is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*timeout)*time.Second) + defer cancel() + + // Check if already confirmed. + if rec := fetchTxRecord(*nodeURL, *txID); rec != nil { + printTxRecord(rec) + return + } + + // Subscribe to SSE and wait for the tx event. + req, err := http.NewRequestWithContext(ctx, "GET", *nodeURL+"/api/events", nil) + if err != nil { + log.Fatalf("create SSE request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Fatalf("connect to SSE: %v", err) + } + defer resp.Body.Close() + + fmt.Printf("Waiting for tx %s (timeout %ds)…\n", *txID, *timeout) + scanner := bufio.NewScanner(resp.Body) + var eventType string + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "event:") { + eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + continue + } + if strings.HasPrefix(line, "data:") && eventType == "tx" { + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + var evt struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(data), &evt); err == nil && evt.ID == *txID { + // Confirmed — fetch full record. + if rec := fetchTxRecord(*nodeURL, *txID); rec != nil { + printTxRecord(rec) + } else { + fmt.Printf("tx_id: %s — confirmed (block info pending)\n", *txID) + } + return + } + } + if line == "" { + eventType = "" + } + } + if ctx.Err() != nil { + log.Fatalf("timeout: tx %s not confirmed within %ds", *txID, *timeout) + } +} + +func fetchTxRecord(nodeURL, txID string) *blockchain.TxRecord { + resp, err := http.Get(nodeURL + "/api/tx/" + txID) + if err != nil || resp.StatusCode != http.StatusOK { + return nil + } + defer resp.Body.Close() + var rec blockchain.TxRecord + if err := json.NewDecoder(resp.Body).Decode(&rec); err != nil { + return nil + } + if rec.Tx == nil { + return nil + } + return &rec +} + +func printTxRecord(rec *blockchain.TxRecord) { + fmt.Printf("Confirmed:\n tx_id: %s\n type: %s\n block: %d (%s)\n block_hash: %s\n", + rec.Tx.ID, rec.Tx.Type, rec.BlockIndex, rec.BlockTime.UTC().Format(time.RFC3339), rec.BlockHash) + if rec.GasUsed > 0 { + fmt.Printf(" gas_used: %d\n", rec.GasUsed) + } +} + +// --- issue-token --- + +func cmdIssueToken(args []string) { + fs := flag.NewFlagSet("issue-token", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + name := fs.String("name", "", "token name (e.g. \"My Token\")") + symbol := fs.String("symbol", "", "ticker symbol (e.g. MTK)") + decimals := fs.Uint("decimals", 6, "decimal places") + supply := fs.Uint64("supply", 0, "initial supply in base units") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *name == "" || *symbol == "" { + log.Fatal("--name and --symbol are required") + } + if *supply == 0 { + log.Fatal("--supply must be > 0") + } + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.IssueTokenPayload{ + Name: *name, + Symbol: *symbol, + Decimals: uint8(*decimals), + TotalSupply: *supply, + }) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventIssueToken, + From: id.PubKeyHex(), + Fee: blockchain.MinIssueTokenFee, + Memo: fmt.Sprintf("Issue token %s", *symbol), + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit issue-token: %v", err) + } + fmt.Printf("Token issue submitted: %s\ntx_id: %s\n", result, tx.ID) + // Derive and print the token ID so user knows it immediately. + h := sha256.Sum256([]byte("token:" + id.PubKeyHex() + ":" + *symbol)) + tokenID := hex.EncodeToString(h[:16]) + fmt.Printf("token_id: %s (pending confirmation)\n", tokenID) +} + +// --- transfer-token --- + +func cmdTransferToken(args []string) { + fs := flag.NewFlagSet("transfer-token", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + tokenID := fs.String("token", "", "token ID") + to := fs.String("to", "", "recipient pubkey") + amount := fs.Uint64("amount", 0, "amount in base units") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *tokenID == "" || *to == "" || *amount == 0 { + log.Fatal("--token, --to, and --amount are required") + } + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.TransferTokenPayload{ + TokenID: *tokenID, + Amount: *amount, + }) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventTransferToken, + From: id.PubKeyHex(), + To: *to, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit transfer-token: %v", err) + } + fmt.Printf("Token transfer submitted: %s\ntx_id: %s\n", result, tx.ID) +} + +// --- burn-token --- + +func cmdBurnToken(args []string) { + fs := flag.NewFlagSet("burn-token", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + tokenID := fs.String("token", "", "token ID") + amount := fs.Uint64("amount", 0, "amount to burn in base units") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *tokenID == "" || *amount == 0 { + log.Fatal("--token and --amount are required") + } + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.BurnTokenPayload{ + TokenID: *tokenID, + Amount: *amount, + }) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventBurnToken, + From: id.PubKeyHex(), + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit burn-token: %v", err) + } + fmt.Printf("Token burn submitted: %s\ntx_id: %s\n", result, tx.ID) +} + +// --- token-balance --- + +func cmdTokenBalance(args []string) { + fs := flag.NewFlagSet("token-balance", flag.ExitOnError) + tokenID := fs.String("token", "", "token ID") + address := fs.String("address", "", "pubkey or DC address (omit to list all holders)") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *tokenID == "" { + log.Fatal("--token is required") + } + + if *address != "" { + resp, err := http.Get(*nodeURL + "/api/tokens/" + *tokenID + "/balance/" + *address) + if err != nil { + log.Fatalf("query balance: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) + return + } + + // No address — show token metadata. + resp, err := http.Get(*nodeURL + "/api/tokens/" + *tokenID) + if err != nil { + log.Fatalf("query token: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) +} + +// --- mint-nft --- + +func cmdMintNFT(args []string) { + fs := flag.NewFlagSet("mint-nft", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + name := fs.String("name", "", "NFT name") + desc := fs.String("desc", "", "description") + uri := fs.String("uri", "", "metadata URI (IPFS, https, etc.)") + attrs := fs.String("attrs", "", "JSON attributes e.g. '{\"trait\":\"value\"}'") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *name == "" { + log.Fatal("--name is required") + } + id := loadKey(*keyFile) + txID := fmt.Sprintf("tx-%d", time.Now().UnixNano()) + payload, _ := json.Marshal(blockchain.MintNFTPayload{ + Name: *name, + Description: *desc, + URI: *uri, + Attributes: *attrs, + }) + tx := &blockchain.Transaction{ + ID: txID, + Type: blockchain.EventMintNFT, + From: id.PubKeyHex(), + Fee: blockchain.MinMintNFTFee, + Memo: fmt.Sprintf("Mint NFT: %s", *name), + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit mint-nft: %v", err) + } + // Derive NFT ID so user sees it immediately. + h := sha256.Sum256([]byte("nft:" + id.PubKeyHex() + ":" + txID)) + nftID := hex.EncodeToString(h[:16]) + fmt.Printf("NFT mint submitted: %s\ntx_id: %s\nnft_id: %s (pending confirmation)\n", result, txID, nftID) +} + +// --- transfer-nft --- + +func cmdTransferNFT(args []string) { + fs := flag.NewFlagSet("transfer-nft", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + nftID := fs.String("nft", "", "NFT ID") + to := fs.String("to", "", "recipient pubkey") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *nftID == "" || *to == "" { + log.Fatal("--nft and --to are required") + } + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.TransferNFTPayload{NFTID: *nftID}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventTransferNFT, + From: id.PubKeyHex(), + To: *to, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit transfer-nft: %v", err) + } + fmt.Printf("NFT transfer submitted: %s\ntx_id: %s\n", result, tx.ID) +} + +// --- burn-nft --- + +func cmdBurnNFT(args []string) { + fs := flag.NewFlagSet("burn-nft", flag.ExitOnError) + keyFile := fs.String("key", "key.json", "identity file") + nftID := fs.String("nft", "", "NFT ID") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + if *nftID == "" { + log.Fatal("--nft is required") + } + id := loadKey(*keyFile) + payload, _ := json.Marshal(blockchain.BurnNFTPayload{NFTID: *nftID}) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", time.Now().UnixNano()), + Type: blockchain.EventBurnNFT, + From: id.PubKeyHex(), + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + result, err := postTx(*nodeURL, tx) + if err != nil { + log.Fatalf("submit burn-nft: %v", err) + } + fmt.Printf("NFT burn submitted: %s\ntx_id: %s\n", result, tx.ID) +} + +// --- nft-info --- + +func cmdNFTInfo(args []string) { + fs := flag.NewFlagSet("nft-info", flag.ExitOnError) + nftID := fs.String("nft", "", "NFT ID (omit to list by owner)") + owner := fs.String("owner", "", "owner pubkey (list NFTs for this address)") + nodeURL := fs.String("node", "http://localhost:8080", "node API URL") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + var url string + if *owner != "" { + url = *nodeURL + "/api/nfts/owner/" + *owner + } else if *nftID != "" { + url = *nodeURL + "/api/nfts/" + *nftID + } else { + url = *nodeURL + "/api/nfts" + } + resp, err := http.Get(url) + if err != nil { + log.Fatalf("query NFT: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + fmt.Println(string(body)) +} diff --git a/cmd/loadtest/main.go b/cmd/loadtest/main.go new file mode 100644 index 0000000..bdb1e65 --- /dev/null +++ b/cmd/loadtest/main.go @@ -0,0 +1,402 @@ +// Command loadtest — probes a running DChain cluster with N concurrent +// WebSocket clients, each subscribing to its own address and submitting +// periodic TRANSFER transactions. +// +// Goal: smoke-test the WS gateway, submit_tx path, native contracts, and +// mempool fairness end-to-end. Catches deadlocks / leaks that unit tests +// miss because they don't run the full stack. +// +// Usage: +// +// go run ./cmd/loadtest \ +// --node http://localhost:8081 \ +// --funder testdata/node1.json \ +// --clients 50 \ +// --duration 60s \ +// --tx-per-client-per-sec 1 +// +// Exits non-zero if: +// - chain tip doesn't advance during the run (consensus stuck) +// - any client's WS connection drops and fails to reconnect +// - mempool-reject rate exceeds 10% +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + + "go-blockchain/blockchain" + "go-blockchain/identity" +) + +func main() { + nodeURL := flag.String("node", "http://localhost:8081", "node HTTP base URL") + funderKey := flag.String("funder", "testdata/node1.json", "path to key file with balance used to fund the test clients") + numClients := flag.Int("clients", 50, "number of concurrent clients") + duration := flag.Duration("duration", 30*time.Second, "how long to run the load test") + txRate := flag.Float64("tx-per-client-per-sec", 1.0, "how fast each client should submit TRANSFER txs") + fundAmount := flag.Uint64("fund-amount", 100_000, "µT sent to each client before the test begins") + flag.Parse() + + funder := loadKeyFile(*funderKey) + log.Printf("[loadtest] funder: %s", funder.PubKeyHex()[:12]) + + ctx, cancel := context.WithTimeout(context.Background(), *duration+1*time.Minute) + defer cancel() + + // --- 1. Generate N throw-away client identities --- + clients := make([]*identity.Identity, *numClients) + for i := range clients { + clients[i] = newEphemeralIdentity() + } + log.Printf("[loadtest] generated %d client identities", *numClients) + + // --- 2. Fund them all — throttle to stay below the node's per-IP + // submit rate limiter (~10/s with burst 20). Loadtest runs from a + // single IP so it'd hit that defence immediately otherwise. + log.Printf("[loadtest] funding each client with %d µT…", *fundAmount) + startHeight := mustNetstats(*nodeURL).TotalBlocks + for _, c := range clients { + if err := submitTransfer(*nodeURL, funder, c.PubKeyHex(), *fundAmount); err != nil { + log.Fatalf("fund client: %v", err) + } + time.Sleep(120 * time.Millisecond) + } + // Wait for all funding txs to commit. We budget 60s at a conservative + // 1 block / 3-5 s PBFT cadence — plenty for dozens of fundings to + // round-robin into blocks. We only require ONE block of advance as + // the "chain is alive" signal; real check is via balance query below. + if err := waitTipAdvance(ctx, *nodeURL, startHeight, 1, 60*time.Second); err != nil { + log.Fatalf("funding didn't commit: %v", err) + } + // Poll until every client has a non-zero balance — that's the real + // signal that funding landed, independent of block-count guesses. + if err := waitAllFunded(ctx, *nodeURL, clients, *fundAmount, 90*time.Second); err != nil { + log.Fatalf("funding balance check: %v", err) + } + log.Printf("[loadtest] funding complete; starting traffic") + + // --- 3. Kick off N client goroutines --- + var ( + accepted atomic.Uint64 + rejected atomic.Uint64 + wsDrops atomic.Uint64 + ) + var wg sync.WaitGroup + runCtx, runCancel := context.WithTimeout(ctx, *duration) + defer runCancel() + + for i, c := range clients { + wg.Add(1) + go func(idx int, id *identity.Identity) { + defer wg.Done() + runClient(runCtx, *nodeURL, id, clients, *txRate, &accepted, &rejected, &wsDrops) + }(i, c) + } + + // --- 4. Monitor chain progression while the test runs --- + monitorDone := make(chan struct{}) + go func() { + defer close(monitorDone) + lastHeight := startHeight + lastTime := time.Now() + t := time.NewTicker(5 * time.Second) + defer t.Stop() + for { + select { + case <-runCtx.Done(): + return + case <-t.C: + s := mustNetstats(*nodeURL) + blkPerSec := float64(s.TotalBlocks-lastHeight) / time.Since(lastTime).Seconds() + log.Printf("[loadtest] tip=%d (%.1f blk/s) accepted=%d rejected=%d ws-drops=%d", + s.TotalBlocks, blkPerSec, + accepted.Load(), rejected.Load(), wsDrops.Load()) + lastHeight = s.TotalBlocks + lastTime = time.Now() + } + } + }() + + wg.Wait() + runCancel() + <-monitorDone + + // --- 5. Final verdict --- + finalHeight := mustNetstats(*nodeURL).TotalBlocks + acc := accepted.Load() + rej := rejected.Load() + total := acc + rej + log.Printf("[loadtest] DONE: startHeight=%d endHeight=%d (Δ=%d blocks)", + startHeight, finalHeight, finalHeight-startHeight) + log.Printf("[loadtest] txs: accepted=%d rejected=%d (%.1f%% reject rate)", + acc, rej, 100*float64(rej)/float64(max1(total))) + log.Printf("[loadtest] ws-drops=%d", wsDrops.Load()) + + if finalHeight <= startHeight { + log.Fatalf("FAIL: chain did not advance during the test") + } + if rej*10 > total { + log.Fatalf("FAIL: reject rate > 10%% (%d of %d)", rej, total) + } + log.Printf("PASS") +} + +// ─── Client loop ────────────────────────────────────────────────────────────── + +func runClient( + ctx context.Context, + nodeURL string, + self *identity.Identity, + all []*identity.Identity, + txRate float64, + accepted, rejected, wsDrops *atomic.Uint64, +) { + wsURL := toWSURL(nodeURL) + "/api/ws" + conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) + if err != nil { + wsDrops.Add(1) + return + } + defer conn.Close() + + // Read hello, then authenticate. + var hello struct { + Event string `json:"event"` + AuthNonce string `json:"auth_nonce"` + } + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + if err := conn.ReadJSON(&hello); err != nil { + wsDrops.Add(1) + return + } + conn.SetReadDeadline(time.Time{}) + sig := ed25519.Sign(self.PrivKey, []byte(hello.AuthNonce)) + _ = conn.WriteJSON(map[string]any{ + "op": "auth", + "pubkey": self.PubKeyHex(), + "sig": hex.EncodeToString(sig), + }) + // Subscribe to our own addr topic. + _ = conn.WriteJSON(map[string]any{ + "op": "subscribe", + "topic": "addr:" + self.PubKeyHex(), + }) + + // Drain incoming frames in a background goroutine so the socket stays + // alive while we submit. + go func() { + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + + // Submit txs at the requested rate. + interval := time.Duration(float64(time.Second) / txRate) + t := time.NewTicker(interval) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + peer := all[randIndex(len(all))] + if peer.PubKeyHex() == self.PubKeyHex() { + continue // don't transfer to self + } + err := submitTransfer(nodeURL, self, peer.PubKeyHex(), 1) + if err != nil { + rejected.Add(1) + } else { + accepted.Add(1) + } + } + } +} + +// ─── HTTP helpers ───────────────────────────────────────────────────────────── + +func submitTransfer(nodeURL string, from *identity.Identity, toHex string, amount uint64) error { + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("lt-%d-%x", time.Now().UnixNano(), randBytes(4)), + Type: blockchain.EventTransfer, + From: from.PubKeyHex(), + To: toHex, + Amount: amount, + Fee: blockchain.MinFee, + Timestamp: time.Now().UTC(), + } + tx.Signature = from.Sign(identity.TxSignBytes(tx)) + + body, _ := json.Marshal(tx) + resp, err := http.Post(nodeURL+"/api/tx", "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("status %d: %s", resp.StatusCode, string(b)) + } + return nil +} + +type netStats struct { + TotalBlocks uint64 `json:"total_blocks"` + TotalTxs uint64 `json:"total_txs"` +} + +func mustNetstats(nodeURL string) netStats { + resp, err := http.Get(nodeURL + "/api/netstats") + if err != nil { + log.Fatalf("netstats: %v", err) + } + defer resp.Body.Close() + var s netStats + if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { + log.Fatalf("decode netstats: %v", err) + } + return s +} + +func waitTipAdvance(ctx context.Context, nodeURL string, from, minDelta uint64, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + s := mustNetstats(nodeURL) + if s.TotalBlocks >= from+minDelta { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } + return fmt.Errorf("tip did not advance by %d within %s", minDelta, timeout) +} + +// waitAllFunded polls /api/address/ for each client until their +// balance reaches fundAmount. More reliable than block-count heuristics +// because it verifies the funding txs were actually applied (not just +// that SOME blocks committed — empty blocks wouldn't fund anyone). +func waitAllFunded(ctx context.Context, nodeURL string, clients []*identity.Identity, fundAmount uint64, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + allFunded := true + for _, c := range clients { + resp, err := http.Get(nodeURL + "/api/address/" + c.PubKeyHex()) + if err != nil { + allFunded = false + break + } + var body struct{ BalanceUT uint64 `json:"balance_ut"` } + _ = json.NewDecoder(resp.Body).Decode(&body) + resp.Body.Close() + if body.BalanceUT < fundAmount { + allFunded = false + break + } + } + if allFunded { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(1 * time.Second): + } + } + return fmt.Errorf("not all clients funded within %s", timeout) +} + +// ─── Identity helpers ───────────────────────────────────────────────────────── + +func newEphemeralIdentity() *identity.Identity { + id, err := identity.Generate() + if err != nil { + log.Fatalf("genkey: %v", err) + } + return id +} + +// loadKeyFile reads the same JSON shape cmd/client uses (PubKey/PrivKey +// as hex strings, optional X25519 pair) and returns an Identity. +func loadKeyFile(path string) *identity.Identity { + data, err := os.ReadFile(path) + if err != nil { + log.Fatalf("read funder key %s: %v", path, err) + } + var k struct { + PubKey string `json:"pub_key"` + PrivKey string `json:"priv_key"` + X25519Pub string `json:"x25519_pub"` + X25519Priv string `json:"x25519_priv"` + } + if err := json.Unmarshal(data, &k); err != nil { + log.Fatalf("parse funder key: %v", err) + } + id, err := identity.FromHexFull(k.PubKey, k.PrivKey, k.X25519Pub, k.X25519Priv) + if err != nil { + log.Fatalf("load funder identity: %v", err) + } + return id +} + +// ─── Misc ───────────────────────────────────────────────────────────────────── + +func toWSURL(httpURL string) string { + u, _ := url.Parse(httpURL) + switch u.Scheme { + case "https": + u.Scheme = "wss" + default: + u.Scheme = "ws" + } + return u.String() +} + +func randBytes(n int) []byte { + b := make([]byte, n) + _, _ = rand.Read(b) + return b +} + +func randIndex(n int) int { + var b [8]byte + _, _ = rand.Read(b[:]) + v := 0 + for _, x := range b { + v = (v*256 + int(x)) & 0x7fffffff + } + return v % n +} + +func max1(x uint64) uint64 { + if x == 0 { + return 1 + } + return x +} + +// Silence unused-imports warning when building on platforms that don't +// need them. All imports above ARE used in the file; this is belt + braces. +var _ = os.Exit diff --git a/cmd/node/main.go b/cmd/node/main.go new file mode 100644 index 0000000..83fe3d2 --- /dev/null +++ b/cmd/node/main.go @@ -0,0 +1,1578 @@ +// cmd/node — full validator node with stats HTTP API. +// +// Flags: +// +// --db BadgerDB directory (default: ./chaindata) +// --listen libp2p listen multiaddr (default: /ip4/0.0.0.0/tcp/4001) +// --announce comma-separated multiaddrs to advertise to peers +// Required for internet deployment (VPS, Docker with fixed IP). +// Example: /ip4/1.2.3.4/tcp/4001 +// Without this flag libp2p tries UPnP/NAT-PMP auto-detection. +// --peers comma-separated bootstrap peer multiaddrs +// --validators comma-separated validator pub keys +// --genesis create genesis block on first start +// --key path to identity JSON file (default: ./node.json) +// --stats-addr HTTP stats server address (default: :8080) +// --wallet path to payout wallet JSON (optional) +// --wallet-pass passphrase for wallet file (optional) +// --heartbeat enable periodic heartbeats (default: true) +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + libp2ppeer "github.com/libp2p/go-libp2p/core/peer" + "github.com/multiformats/go-multiaddr" + + "go-blockchain/blockchain" + "go-blockchain/consensus" + "go-blockchain/economy" + "go-blockchain/identity" + "go-blockchain/node" + "go-blockchain/node/version" + "go-blockchain/p2p" + "go-blockchain/relay" + "go-blockchain/vm" + "go-blockchain/wallet" +) + +const heartbeatFeeUT = 50_000 // 0.05 T + +// expectedGenesisHash is populated from the --join seed's /api/network-info +// response. After sync completes we verify our local block 0 hashes to the +// same value; mismatch aborts the node (unless --allow-genesis-mismatch). +// Empty string means no --join: skip verification. +var expectedGenesisHash string + +func main() { + // Every flag below also reads a DCHAIN_* env var fallback (see envOr / + // envBoolOr / envUint64Or). Flag on CLI wins; env on container deploys; + // hard-coded default as last resort. Lets docker-compose drive the node + // from an env_file without bake-in CLI strings. + dbPath := flag.String("db", envOr("DCHAIN_DB", "./chaindata"), "BadgerDB directory (env: DCHAIN_DB)") + listenAddr := flag.String("listen", envOr("DCHAIN_LISTEN", "/ip4/0.0.0.0/tcp/4001"), "libp2p listen multiaddr (env: DCHAIN_LISTEN)") + announceFlag := flag.String("announce", envOr("DCHAIN_ANNOUNCE", ""), "comma-separated multiaddrs to advertise to peers (env: DCHAIN_ANNOUNCE)") + peersFlag := flag.String("peers", envOr("DCHAIN_PEERS", ""), "comma-separated bootstrap peer multiaddrs (env: DCHAIN_PEERS)") + validators := flag.String("validators", envOr("DCHAIN_VALIDATORS", ""), "comma-separated validator pub keys (env: DCHAIN_VALIDATORS)") + genesisFlag := flag.Bool("genesis", envBoolOr("DCHAIN_GENESIS", false), "create genesis block (env: DCHAIN_GENESIS)") + keyFile := flag.String("key", envOr("DCHAIN_KEY", "./node.json"), "path to identity JSON file (env: DCHAIN_KEY)") + relayKeyFile := flag.String("relay-key", envOr("DCHAIN_RELAY_KEY", "./relay.json"), "path to relay X25519 keypair JSON (env: DCHAIN_RELAY_KEY)") + statsAddr := flag.String("stats-addr", envOr("DCHAIN_STATS_ADDR", ":8080"), "HTTP stats server address (env: DCHAIN_STATS_ADDR)") + walletFile := flag.String("wallet", envOr("DCHAIN_WALLET", ""), "payout wallet JSON (env: DCHAIN_WALLET)") + walletPass := flag.String("wallet-pass", envOr("DCHAIN_WALLET_PASS", ""), "passphrase for wallet file (env: DCHAIN_WALLET_PASS)") + heartbeatEnabled := flag.Bool("heartbeat", envBoolOr("DCHAIN_HEARTBEAT", true), "enable periodic heartbeat transactions (env: DCHAIN_HEARTBEAT)") + registerRelay := flag.Bool("register-relay", envBoolOr("DCHAIN_REGISTER_RELAY", false), "submit REGISTER_RELAY tx on startup (env: DCHAIN_REGISTER_RELAY)") + relayFee := flag.Uint64("relay-fee", envUint64Or("DCHAIN_RELAY_FEE", 1_000), "relay fee per message in µT (env: DCHAIN_RELAY_FEE)") + mailboxDB := flag.String("mailbox-db", envOr("DCHAIN_MAILBOX_DB", "./mailboxdata"), "BadgerDB directory for relay mailbox (env: DCHAIN_MAILBOX_DB)") + govContractID := flag.String("governance-contract", envOr("DCHAIN_GOVERNANCE_CONTRACT", ""), "governance contract ID for dynamic chain parameters (env: DCHAIN_GOVERNANCE_CONTRACT)") + joinSeedURL := flag.String("join", envOr("DCHAIN_JOIN", ""), "bootstrap from a running node: comma-separated HTTP URLs (env: DCHAIN_JOIN)") + // Observer mode: the node participates in the P2P network, applies + // gossiped blocks, serves HTTP/WS, and forwards submitted txs — but + // never proposes blocks or votes in PBFT. Use this for read-heavy + // deployments: put N observers behind a load balancer so clients can + // hammer them without hitting validators directly. Observers also + // never need to be in the validator set. + observerMode := flag.Bool("observer", envBoolOr("DCHAIN_OBSERVER", false), "observer-only mode: apply blocks + serve HTTP but don't propose or vote (env: DCHAIN_OBSERVER)") + // Log format: `text` (default, human-readable) or `json` (production, + // machine-parsable for Loki/ELK). Applies to both the global slog + // handler and Go's std log package — existing log.Printf calls are + // rerouted through slog so old and new log sites share a format. + logFormat := flag.String("log-format", envOr("DCHAIN_LOG_FORMAT", "text"), "log format: `text` or `json` (env: DCHAIN_LOG_FORMAT)") + // Access control for the HTTP/WS API. Empty token = fully public node + // (default). Non-empty token gates /api/tx (and WS submit_tx) behind + // Authorization: Bearer . Add --api-private to also gate every + // read endpoint — useful for personal nodes where the operator + // considers chat metadata private. See node/api_guards.go. + apiToken := flag.String("api-token", envOr("DCHAIN_API_TOKEN", ""), "Bearer token required to submit transactions (env: DCHAIN_API_TOKEN). Empty = public node") + apiPrivate := flag.Bool("api-private", envBoolOr("DCHAIN_API_PRIVATE", false), "also require the access token on READ endpoints (env: DCHAIN_API_PRIVATE)") + updateSrcURL := flag.String("update-source-url", envOr("DCHAIN_UPDATE_SOURCE_URL", ""), "Gitea /api/v1/repos/{owner}/{repo}/releases/latest URL for /api/update-check (env: DCHAIN_UPDATE_SOURCE_URL)") + updateSrcToken := flag.String("update-source-token", envOr("DCHAIN_UPDATE_SOURCE_TOKEN", ""), "optional Gitea PAT used when polling a private repo (env: DCHAIN_UPDATE_SOURCE_TOKEN)") + disableUI := flag.Bool("disable-ui", envBoolOr("DCHAIN_DISABLE_UI", false), "do not register HTML block-explorer pages — JSON API and Swagger still work (env: DCHAIN_DISABLE_UI)") + disableSwagger := flag.Bool("disable-swagger", envBoolOr("DCHAIN_DISABLE_SWAGGER", false), "do not register /swagger* endpoints (env: DCHAIN_DISABLE_SWAGGER)") + // When --join is set, the seed's genesis_hash becomes the expected value + // for our own block 0. After sync completes we compare; on mismatch we + // refuse to start (fail loud) — otherwise a malicious or misconfigured + // seed could silently trick us onto a parallel chain. Use --allow-genesis-mismatch + // only for intentional migrations (e.g. importing data from another chain + // into this network) — very dangerous. + allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.") + showVersion := flag.Bool("version", false, "print version info and exit") + flag.Parse() + + if *showVersion { + // Print one-line version summary then exit. Used by update.sh smoke + // test and operators running `node --version`. + fmt.Println(version.String()) + return + } + + // Configure structured logging. Must run before any log.Printf call + // so subsequent logs inherit the format. + setupLogging(*logFormat) + + // Wire API access-control. A non-empty token gates writes; adding + // --api-private also gates reads. Logged up-front so the operator + // sees what mode they're in. + node.SetAPIAccess(*apiToken, *apiPrivate) + node.SetUpdateSource(*updateSrcURL, *updateSrcToken) + switch { + case *apiToken == "": + log.Printf("[NODE] API access: public (no token set)") + case *apiPrivate: + log.Printf("[NODE] API access: fully private (token required on all endpoints)") + default: + log.Printf("[NODE] API access: public reads, token-gated writes") + } + + // --- Zero-config onboarding: --join fetches a seed node's network info + // and fills in --peers / --validators from it, so operators adding a new + // node to an existing network don't have to hunt down multiaddrs or + // validator pubkeys by hand. + // + // Multi-seed: --join accepts a comma-separated list. We try each URL in + // order until one succeeds. This way a dead seed doesn't orphan a new + // node — just list a couple backups. + // + // Persistence: on first successful --join we write /seeds.json + // with the tried URL list + live peer multiaddrs. On subsequent + // restarts (no --join passed) we read this file and retry seeds + // automatically, so the operator doesn't have to repeat the CLI flag + // on every container restart. + seedFile := filepath.Join(*dbPath, "seeds.json") + seedURLs := parseSeedList(*joinSeedURL) + if len(seedURLs) == 0 { + // Fall back to persisted list from last run, if any. + if persisted, err := loadSeedsFile(seedFile); err == nil && len(persisted.URLs) > 0 { + seedURLs = persisted.URLs + log.Printf("[NODE] loaded %d seed URL(s) from %s", len(seedURLs), seedFile) + } + } + if len(seedURLs) > 0 { + info, usedURL := tryJoinSeeds(seedURLs) + if info == nil { + log.Fatalf("[NODE] --join: all %d seeds failed. Check URLs and network.", len(seedURLs)) + } + log.Printf("[NODE] --join: bootstrapped from %s", usedURL) + + if *peersFlag == "" && len(info.Peers) > 0 { + var addrs []string + for _, p := range info.Peers { + for _, a := range p.Addrs { + if a != "" { + addrs = append(addrs, a) + break + } + } + } + *peersFlag = strings.Join(addrs, ",") + log.Printf("[NODE] --join: learned %d peer multiaddrs from seed", len(addrs)) + } + if *validators == "" && len(info.Validators) > 0 { + *validators = strings.Join(info.Validators, ",") + log.Printf("[NODE] --join: learned %d validators from seed", len(info.Validators)) + } + if *govContractID == "" { + if gov, ok := info.Contracts["governance"]; ok && gov.ContractID != "" { + *govContractID = gov.ContractID + log.Printf("[NODE] --join: governance contract = %s", gov.ContractID) + } + } + if info.ChainID != "" { + log.Printf("[NODE] --join: connecting to chain %s (tip=%d)", info.ChainID, info.TipHeight) + } + if info.GenesisHash != "" { + expectedGenesisHash = info.GenesisHash + log.Printf("[NODE] --join: expected genesis hash = %s", info.GenesisHash) + } + + // Persist the successful URL first, then others, so retries pick + // the known-good one first. Include the current peer list so the + // next cold boot can bypass the HTTP layer entirely if any peer + // is still up. + var orderedURLs []string + orderedURLs = append(orderedURLs, usedURL) + for _, u := range seedURLs { + if u != usedURL { + orderedURLs = append(orderedURLs, u) + } + } + if err := saveSeedsFile(seedFile, &persistedSeeds{ + ChainID: info.ChainID, + GenesisHash: info.GenesisHash, + URLs: orderedURLs, + Peers: *peersFlag, + SavedAt: time.Now().UTC().Format(time.RFC3339), + }); err != nil { + log.Printf("[NODE] warn: could not persist seeds to %s: %v", seedFile, err) + } + } + + // --- Identity --- + id := loadOrCreateIdentity(*keyFile) + log.Printf("[NODE] pub_key: %s", id.PubKeyHex()) + log.Printf("[NODE] address: %s", wallet.PubKeyToAddress(id.PubKeyHex())) + + // --- Optional payout wallet --- + var payoutWallet *wallet.Wallet + if *walletFile != "" { + w, err := wallet.Load(*walletFile, *walletPass) + if err != nil { + log.Fatalf("[NODE] load wallet: %v", err) + } + payoutWallet = w + log.Printf("[NODE] payout wallet: %s", w.Short()) + } + + // --- Validator set --- + var valSet []string + if *validators != "" { + for _, v := range strings.Split(*validators, ",") { + if v = strings.TrimSpace(v); v != "" { + valSet = append(valSet, v) + } + } + } + if len(valSet) == 0 { + valSet = []string{id.PubKeyHex()} + } + log.Printf("[NODE] validator set (%d): %v", len(valSet), shortKeys(valSet)) + + // --- Chain --- + chain, err := blockchain.NewChain(*dbPath) + if err != nil { + log.Fatalf("[NODE] open chain: %v", err) + } + defer chain.Close() + log.Printf("[NODE] chain height: %d tip: %s", tipIndex(chain), tipHash(chain)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // One-time catch-up GC: reclaims any garbage that accumulated before + // this version (which introduced the background GC loop). Nodes with + // clean DBs finish in milliseconds; nodes upgraded from pre-GC builds + // may take a minute or two to shrink their value log from multi-GB + // back to actual live size. Safe to skip — the background loop will + // eventually reclaim the same space — but doing it once up front + // makes the upgrade visibly free disk. + go func() { + log.Printf("[NODE] running startup value-log compaction…") + chain.CompactNow() + log.Printf("[NODE] startup compaction done") + }() + + // Background value-log garbage collector for the chain DB. + // Without this, every overwrite of a hot key (`height`, `netstats`) leaves + // the previous bytes pinned in BadgerDB's value log forever — the DB ends + // up multiple gigabytes on disk even when live state is megabytes. Runs + // every 5 minutes; stops automatically when ctx is cancelled. + chain.StartValueLogGC(ctx) + + // Genesis-hash sanity check for --join'd nodes. Run in background so + // startup isn't blocked while waiting for sync to complete on first + // launch, but fatal-error the process if the local genesis eventually + // diverges from what the seed reported. + if expectedGenesisHash != "" { + go startGenesisVerifier(ctx, chain, expectedGenesisHash, *allowGenesisMismatch) + } + + // --- WASM VM --- + contractVM := vm.NewVM(ctx) + defer contractVM.Close(ctx) + chain.SetVM(contractVM) + + // --- Native system contracts --- + // Register built-in Go contracts so they're callable via CALL_CONTRACT + // with the same on-chain semantics as WASM contracts, but without the + // WASM VM in the hot path. The username registry lives here because + // it's the most latency-sensitive service (every chat/contact-add + // does a resolve/reverseResolve). + chain.RegisterNative(blockchain.NewUsernameRegistry()) + log.Printf("[NODE] registered native contract: %s", blockchain.UsernameRegistryID) + + // --- Governance contract (optional) --- + if *govContractID != "" { + chain.SetGovernanceContract(*govContractID) + } + + // --- SSE event hub --- + sseHub := node.NewSSEHub() + + // --- WebSocket gateway --- + // Same event sources as SSE but pushed over a persistent ws:// connection + // so mobile clients don't have to poll. Hello frame carries chain_id + + // tip_height so the client can verify it's on the right chain. + wsHub := node.NewWSHub( + func() string { + if g, err := chain.GetBlock(0); err == nil && g != nil { + return "dchain-" + g.HashHex()[:12] + } + return "dchain-unknown" + }, + chain.TipIndex, + ) + // Event bus: one emit site per event; consumers (SSE, WS, future + // indexers) are registered once below. Eliminates the duplicated + // `go sseHub.EmitX / go wsHub.EmitX` pattern at every commit callsite. + eventBus := node.NewEventBus() + eventBus.Register(node.WrapSSE(sseHub)) + eventBus.Register(node.WrapWS(wsHub)) + + // submit_tx handler is wired after `engine` and `h` are constructed — + // see further down where `wsHub.SetSubmitTxHandler(...)` is called. + + // Auth binding: Ed25519 pubkey → X25519 pubkey from the identity registry. + // Lets the hub enforce `inbox:` subscriptions so a client can + // only listen to their OWN inbox, not anyone else's. + wsHub.SetX25519ForPub(func(edHex string) (string, error) { + info, err := chain.IdentityInfo(edHex) + if err != nil || info == nil { + return "", err + } + return info.X25519Pub, nil + }) + + // Live peer-count gauge is registered below (after `h` is constructed). + + // --- Stats tracker --- + stats := node.NewTracker() + + // --- Announce addresses (internet deployment) --- + // Parse --announce flag into []multiaddr.Multiaddr. + // When set, only these addresses are advertised to peers — libp2p's + // auto-detected addresses (internal interfaces, loopback) are suppressed. + // This is essential when running on a VPS or inside Docker with a fixed IP: + // --announce /ip4/203.0.113.10/tcp/4001 + var announceAddrs []multiaddr.Multiaddr + if *announceFlag != "" { + for _, s := range strings.Split(*announceFlag, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + ma, err := multiaddr.NewMultiaddr(s) + if err != nil { + log.Fatalf("[NODE] bad --announce addr %q: %v", s, err) + } + announceAddrs = append(announceAddrs, ma) + log.Printf("[NODE] announce addr: %s", s) + } + } + + // --- P2P --- + h, err := p2p.NewHost(ctx, id, *listenAddr, announceAddrs) + if err != nil { + log.Fatalf("[NODE] p2p: %v", err) + } + defer h.Close() + + // Start peer-version gossip. Best-effort: errors here are logged but do + // not block node startup — the version map is purely advisory. + if err := h.StartVersionGossip(ctx, node.ProtocolVersion); err != nil { + log.Printf("[NODE] peer-version gossip unavailable: %v", err) + } + + // Wire peer connect/disconnect into stats + // Live peer-count gauge for Prometheus — queried at scrape time. + // Registered here (not earlier) because we need `h` to exist. + node.NewGaugeFunc("dchain_peer_count_live", + "Live libp2p peer count (queried on scrape)", + func() int64 { return int64(h.PeerCount()) }) + + // engine captured below after creation; use a pointer-to-pointer pattern + var engineRef *consensus.Engine + h.OnPeerConnected(func(pid libp2ppeer.ID) { + stats.PeerConnected(pid) + eng := engineRef // capture current value + go syncOnConnect(ctx, h, chain, stats, pid, func(next uint64) { + if eng != nil { + eng.SyncSeqNum(next) + } + }) + }) + + // --- Genesis --- + if *genesisFlag && chain.Tip() == nil { + genesis := blockchain.GenesisBlock(id.PubKeyHex(), id.PrivKey) + if err := chain.AddBlock(genesis); err != nil { + log.Fatalf("[NODE] add genesis: %v", err) + } + log.Printf("[NODE] genesis block: %s", genesis.HashHex()) + } + + // Seed the on-chain validator set from CLI flags (idempotent). + if err := chain.InitValidators(valSet); err != nil { + log.Fatalf("[NODE] init validators: %v", err) + } + // Prefer the on-chain set if it has been extended by ADD_VALIDATOR txs. + if onChain, err := chain.ValidatorSet(); err == nil && len(onChain) > 0 { + valSet = onChain + log.Printf("[NODE] loaded %d validators from chain state", len(valSet)) + } + + // --- Consensus engine --- + var seqNum uint64 + if tip := chain.Tip(); tip != nil { + seqNum = tip.Index + 1 + } + + engine := consensus.NewEngine( + id, valSet, seqNum, + func(b *blockchain.Block) { + // Time the AddBlock call end-to-end so we can see slow commits + // (typically contract calls) in the Prometheus histogram. + commitStart := time.Now() + if err := chain.AddBlock(b); err != nil { + log.Printf("[NODE] add block #%d: %v", b.Index, err) + return + } + node.MetricBlockCommitSeconds.Observe(time.Since(commitStart).Seconds()) + node.MetricBlocksTotal.Inc() + if len(b.Transactions) > 0 { + node.MetricTxsTotal.Add(uint64(len(b.Transactions))) + } + // Remove committed transactions from our mempool so we don't + // re-propose them in the next round (non-proposing validators + // keep txs in their pending list until explicitly pruned). + if engineRef != nil { + engineRef.PruneTxs(b.Transactions) + } + stats.BlocksCommitted.Add(1) + // Push live events to every registered bus consumer (SSE, + // WS, future indexers). Single emit per event type. + go eventBus.EmitBlockWithTxs(b) + go emitContractLogsViaBus(eventBus, chain, b) + + r := economy.ComputeBlockReward(b) + log.Printf("[NODE] %s", r.Summary()) + + // Show balance of whoever receives the reward + rewardKey := b.Validator + if binding, _ := chain.WalletBinding(b.Validator); binding != "" { + rewardKey = binding + } + bal, _ := chain.Balance(rewardKey) + log.Printf("[NODE] reward target %s balance: %s", + wallet.PubKeyToAddress(rewardKey), economy.FormatTokens(bal)) + + // Hot-reload validator set if this block changed it. + for _, tx := range b.Transactions { + if tx.Type == blockchain.EventAddValidator || tx.Type == blockchain.EventRemoveValidator { + if newSet, err := chain.ValidatorSet(); err == nil { + if eng := engineRef; eng != nil { + eng.UpdateValidators(newSet) + } + } + break + } + } + + // Gossip committed block to peers + if err := h.PublishBlock(b); err != nil { + log.Printf("[NODE] publish block: %v", err) + } + stats.BlocksGossipSent.Add(1) + }, + func(msg *blockchain.ConsensusMsg) { + if err := h.BroadcastConsensus(msg); err != nil { + log.Printf("[NODE] broadcast consensus: %v", err) + } + stats.ConsensusMsgsSent.Add(1) + }, + ) + + // Assign engine to the ref captured by peer-connect handler + engineRef = engine + + // Wire the WS submit_tx handler now that `engine` and `h` exist. + // Mirrors the HTTP POST /api/tx path so clients get the same + // validation regardless of transport. + wsHub.SetSubmitTxHandler(func(txJSON []byte) (string, error) { + var tx blockchain.Transaction + if err := json.Unmarshal(txJSON, &tx); err != nil { + return "", fmt.Errorf("invalid tx JSON: %w", err) + } + if err := node.ValidateTxTimestamp(&tx); err != nil { + return "", fmt.Errorf("bad timestamp: %w", err) + } + if err := identity.VerifyTx(&tx); err != nil { + return "", fmt.Errorf("invalid signature: %w", err) + } + if err := engine.AddTransaction(&tx); err != nil { + return "", err + } + if err := h.PublishTx(&tx); err != nil { + // Mempool accepted — gossip failure is non-fatal for the caller. + log.Printf("[NODE] ws submit_tx: gossip publish failed (mempool has it): %v", err) + } + return tx.ID, nil + }) + + // Wrap consensus stats + engine.OnPropose(func() { stats.BlocksProposed.Add(1) }) + engine.OnVote(func() { stats.VotesCast.Add(1) }) + engine.OnViewChange(func() { stats.ViewChanges.Add(1) }) + + // Register direct-stream consensus handler + h.SetConsensusMsgHandler(func(msg *blockchain.ConsensusMsg) { + stats.ConsensusMsgsRecv.Add(1) + engine.HandleMessage(msg) + }) + + // --- Sync protocol handler --- + h.SetSyncHandler( + func(index uint64) (*blockchain.Block, error) { return chain.GetBlock(index) }, + func() uint64 { + if tip := chain.Tip(); tip != nil { + return tip.Index + 1 + } + return 0 + }, + ) + + // --- Bootstrap peers --- + if *peersFlag != "" { + for _, addr := range strings.Split(*peersFlag, ",") { + if addr = strings.TrimSpace(addr); addr != "" { + go connectWithRetry(ctx, h, addr) + } + } + } + + h.Advertise(ctx) + h.DiscoverPeers(ctx) + + // --- Incoming gossip loops --- + txMsgs := h.TxMsgs(ctx) + blockMsgs := h.BlockMsgs(ctx) + + go func() { + for tx := range txMsgs { + stats.TxsGossipRecv.Add(1) + if err := engine.AddTransaction(tx); err != nil { + log.Printf("[NODE] rejected gossip tx %s: %v", tx.ID, err) + continue + } + // Trigger an immediate block proposal so new transactions are + // committed quickly instead of waiting for the next tick. + if engine.IsLeader() { + go engine.Propose(chain.Tip()) + } + } + }() + + // gapFillRecent tracks the last time we asked a given peer to fill in + // missing blocks, so a burst of out-of-order gossips can't cause a + // sync storm. One sync request per peer per minute is plenty — + // SyncFromPeerFull drains whatever is needed in a single call. + gapFillRecent := make(map[libp2ppeer.ID]time.Time) + var gapFillMu sync.Mutex + + go func() { + for m := range blockMsgs { + b := m.Block + stats.BlocksGossipRecv.Add(1) + tip := chain.Tip() + if tip != nil && b.Index <= tip.Index { + continue + } + // Gap detected — we received block N but our tip is N-k for k>1. + // Gossipsub won't replay the missing blocks, so we have to ask + // the gossiper (or any peer) to stream them to us explicitly. + // Without this the node silently falls behind forever and never + // catches up, even though the rest of the network is live. + if tip != nil && b.Index > tip.Index+1 { + gapFillMu.Lock() + last := gapFillRecent[m.From] + if time.Since(last) > 60*time.Second { + gapFillRecent[m.From] = time.Now() + gapFillMu.Unlock() + log.Printf("[NODE] gap detected: gossiped #%d, tip=#%d — requesting sync from %s", + b.Index, tip.Index, m.From) + eng := engineRef + go syncOnConnect(ctx, h, chain, stats, m.From, func(next uint64) { + if eng != nil { + eng.SyncSeqNum(next) + } + }) + } else { + gapFillMu.Unlock() + log.Printf("[NODE] gap detected but sync from %s throttled (cooldown); dropping #%d", + m.From, b.Index) + } + continue + } + if err := chain.AddBlock(b); err != nil { + log.Printf("[NODE] add gossip block #%d: %v", b.Index, err) + continue + } + engine.SyncSeqNum(b.Index + 1) + log.Printf("[NODE] applied gossip block #%d hash=%s", b.Index, b.HashHex()[:8]) + go eventBus.EmitBlockWithTxs(b) + } + }() + + // --- Relay mailbox --- + mailbox, err := relay.OpenMailbox(*mailboxDB) + if err != nil { + log.Fatalf("[NODE] relay mailbox: %v", err) + } + defer mailbox.Close() + go mailbox.RunGC() + log.Printf("[NODE] relay mailbox: %s", *mailboxDB) + + // Push-notify bus consumers whenever a fresh envelope lands in the + // mailbox. Clients subscribed to `inbox:` (via WS) get the + // event immediately so they no longer need to poll /relay/inbox. + // + // We send a minimal summary (no ciphertext) — the client refetches from + // /relay/inbox if it needs the full envelope. Keeps WS frames small and + // avoids a fat push for every message. + mailbox.SetOnStore(func(env *relay.Envelope) { + sum, _ := json.Marshal(map[string]any{ + "id": env.ID, + "recipient_pub": env.RecipientPub, + "sender_pub": env.SenderPub, + "sent_at": env.SentAt, + }) + eventBus.EmitInbox(env.RecipientPub, sum) + }) + + // --- Relay router --- + relayKP, err := relay.LoadOrCreateKeyPair(*relayKeyFile) + if err != nil { + log.Fatalf("[NODE] relay keypair: %v", err) + } + relayRouter, err := relay.NewRouter( + h.LibP2PHost(), + h.GossipSub(), + relayKP, + id, + mailbox, + func(envID, senderPub string, msg []byte) { + senderShort := senderPub + if len(senderShort) > 8 { + senderShort = senderShort[:8] + } + log.Printf("[RELAY] delivered envelope %s from %s (%d bytes)", envID, senderShort, len(msg)) + }, + func(tx *blockchain.Transaction) error { + if err := engine.AddTransaction(tx); err != nil { + return err + } + return h.PublishTx(tx) + }, + ) + if err != nil { + log.Fatalf("[NODE] relay router: %v", err) + } + go relayRouter.Run(ctx) + log.Printf("[NODE] relay pub key: %s", relayRouter.RelayPubHex()) + + // --- Register relay service on-chain (optional) --- + // Observers can still act as relays (they forward encrypted envelopes + // and serve /relay/inbox the same way validators do) — but only if + // the operator explicitly asks. Default-off for observers avoids + // surprise on-chain registration txs. + if *registerRelay && !*observerMode { + go autoRegisterRelay(ctx, id, relayKP, *relayFee, h.AddrStrings(), chain, engine, h) + } else if *registerRelay && *observerMode { + log.Printf("[NODE] --register-relay ignored in observer mode; run with both flags off an observer to register as relay") + } + + // --- Validator liveness metric updater --- + // Engine tracks per-validator last-seen seqNum; we scan periodically + // and publish the worst offender into Prometheus so alerts can fire. + go func() { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + report := engine.LivenessReport() + var worst uint64 + for _, missed := range report { + if missed > worst { + worst = missed + } + } + node.MetricMaxMissedBlocks.Set(int64(worst)) + } + } + }() + + // --- Periodic heartbeat transaction --- + // Observers don't heartbeat — they're not validators, their uptime + // doesn't affect consensus, and heartbeat is an on-chain fee-paying + // tx that would waste tokens. + if *heartbeatEnabled && !*observerMode { + go heartbeatLoop(ctx, id, chain, h, stats) + } else if *observerMode { + log.Printf("[NODE] heartbeat disabled (observer mode)") + } else { + log.Printf("[NODE] heartbeat disabled by config") + } + + // --- Bind wallet at startup if provided and not yet bound --- + if payoutWallet != nil { + go autoBindWallet(ctx, id, payoutWallet, chain, engine) + } + + // --- Block production loop (leader only) --- + // + // Two tiers: + // fastTicker (500 ms) — proposes only when the mempool is non-empty. + // Keeps tx latency low without flooding empty blocks. + // idleTicker (5 s) — heartbeat: proposes even if the mempool is empty + // so the genesis block is produced quickly on startup + // and the chain height advances so peers can sync. + // Observer mode skips the producer entirely — they never propose or + // vote. They still receive blocks via gossipsub and forward txs, so + // this keeps them light-weight + certain not to participate in + // consensus even if erroneously listed as a validator. + if *observerMode { + log.Printf("[NODE] observer mode: block producer disabled") + } + go func() { + if *observerMode { + return + } + fastTicker := time.NewTicker(500 * time.Millisecond) + idleTicker := time.NewTicker(5 * time.Second) + // heartbeatTicker: independent of tip-reads and mempool. Its sole job + // is to prove this producer goroutine is actually alive so we can + // tell "chain frozen because consensus halted" apart from "chain + // frozen because producer deadlocked on c.mu". Logs at most once per + // minute; should appear continuously on any live node. + lifeTicker := time.NewTicker(60 * time.Second) + defer fastTicker.Stop() + defer idleTicker.Stop() + defer lifeTicker.Stop() + lastProposedHeight := uint64(0) + for { + select { + case <-ctx.Done(): + return + case <-fastTicker.C: + if engine.IsLeader() && engine.HasPendingTxs() { + tip := chain.Tip() + engine.Propose(tip) + if tip != nil { + lastProposedHeight = tip.Index + } + } + case <-idleTicker.C: + if engine.IsLeader() { + tip := chain.Tip() + engine.Propose(tip) // heartbeat block, may be empty + if tip != nil { + lastProposedHeight = tip.Index + } + } + case <-lifeTicker.C: + // Proof of life + last proposal height so we can see from + // logs whether the loop is running AND reaching the chain. + log.Printf("[NODE] producer alive (leader=%v lastProposed=%d tip=%d)", + engine.IsLeader(), lastProposedHeight, chain.TipIndex()) + } + } + }() + + // --- Stats HTTP server --- + statsQuery := node.QueryFunc{ + PubKey: func() string { return id.PubKeyHex() }, + PeerID: func() string { return h.PeerID() }, + PeersCount: func() int { return h.PeerCount() }, + ChainTip: func() *blockchain.Block { return chain.Tip() }, + Balance: func(pk string) uint64 { + b, _ := chain.Balance(pk) + return b + }, + WalletBinding: func(pk string) string { + binding, _ := chain.WalletBinding(pk) + if binding == "" { + return "" + } + return wallet.PubKeyToAddress(binding) + }, + Reputation: func(pk string) blockchain.RepStats { + r, _ := chain.Reputation(pk) + return r + }, + } + explorerQuery := node.ExplorerQuery{ + GetBlock: chain.GetBlock, + GetTx: chain.TxByID, + AddressToPubKey: chain.AddressToPubKey, + Balance: func(pk string) (uint64, error) { return chain.Balance(pk) }, + Reputation: func(pk string) (blockchain.RepStats, error) { + return chain.Reputation(pk) + }, + WalletBinding: func(pk string) (string, error) { + return chain.WalletBinding(pk) + }, + TxsByAddress: func(pk string, limit, offset int) ([]*blockchain.TxRecord, error) { + return chain.TxsByAddress(pk, limit, offset) + }, + RecentBlocks: chain.RecentBlocks, + RecentTxs: chain.RecentTxs, + NetStats: chain.NetworkStats, + RegisteredRelays: chain.RegisteredRelays, + IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) { + return chain.IdentityInfo(pubKeyOrAddr) + }, + ValidatorSet: chain.ValidatorSet, + SubmitTx: func(tx *blockchain.Transaction) error { + if err := engine.AddTransaction(tx); err != nil { + return err + } + return h.PublishTx(tx) + }, + GetContract: chain.GetContract, + GetContracts: chain.Contracts, + GetContractState: chain.GetContractState, + GetContractLogs: chain.ContractLogs, + Stake: chain.Stake, + GetToken: chain.Token, + GetTokens: chain.Tokens, + TokenBalance: chain.TokenBalance, + GetNFT: chain.NFT, + GetNFTs: chain.NFTs, + NFTsByOwner: chain.NFTsByOwner, + GetChannel: chain.Channel, + GetChannelMembers: chain.ChannelMembers, + Events: sseHub, + WS: wsHub, + // Onboarding: expose libp2p peers + chain_id so new nodes/clients can + // fetch /api/network-info to bootstrap without static configuration. + ConnectedPeers: func() []node.ConnectedPeerRef { + if h == nil { + return nil + } + src := h.ConnectedPeers() + // Pull peer-version map once per call so the N peers share a + // consistent snapshot rather than a racing one. + versions := h.PeerVersions() + out := make([]node.ConnectedPeerRef, len(src)) + for i, p := range src { + ref := node.ConnectedPeerRef{ID: p.ID, Addrs: p.Addrs} + if pv, ok := versions[p.ID]; ok { + ref.Version = &node.PeerVersionRef{ + Tag: pv.Tag, + Commit: pv.Commit, + ProtocolVersion: pv.ProtocolVersion, + Timestamp: pv.Timestamp, + ReceivedAt: pv.ReceivedAt.Format(time.RFC3339), + } + } + out[i] = ref + } + return out + }, + ChainID: func() string { + // Derive from genesis block so every node with the same genesis + // reports the same chain_id. Fall back to a static string if the + // chain has no genesis yet (fresh DB, pre-sync). + if g, err := chain.GetBlock(0); err == nil && g != nil { + return "dchain-" + g.HashHex()[:12] + } + return "dchain-unknown" + }, + // Native contracts registered with the chain are exposed through the + // well-known endpoint so clients don't have to know which ones are + // native vs WASM. They use the contract_id transparently. + NativeContracts: func() []node.NativeContractInfo { + all := chain.NativeContracts() + out := make([]node.NativeContractInfo, len(all)) + for i, nc := range all { + out[i] = node.NativeContractInfo{ + ContractID: nc.ID(), + ABIJson: nc.ABI(), + } + } + return out + }, + } + + relayConfig := node.RelayConfig{ + Mailbox: mailbox, + Send: func(recipientPubHex string, msg []byte) (string, error) { + return relayRouter.Send(recipientPubHex, msg, 0) + }, + Broadcast: func(env *relay.Envelope) error { + return relayRouter.Broadcast(env) + }, + ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) { + return chain.ContactRequests(pubKey) + }, + } + + go func() { + log.Printf("[NODE] stats API: http://0.0.0.0%s/stats", *statsAddr) + if *disableUI { + log.Printf("[NODE] explorer UI: disabled (--disable-ui)") + } else { + log.Printf("[NODE] explorer UI: http://0.0.0.0%s/", *statsAddr) + } + if *disableSwagger { + log.Printf("[NODE] swagger: disabled (--disable-swagger)") + } else { + log.Printf("[NODE] swagger: http://0.0.0.0%s/swagger", *statsAddr) + } + log.Printf("[NODE] relay inbox: http://0.0.0.0%s/relay/inbox?pub=", *statsAddr) + routeFlags := node.ExplorerRouteFlags{ + DisableUI: *disableUI, + DisableSwagger: *disableSwagger, + } + if err := stats.ListenAndServe(*statsAddr, statsQuery, func(mux *http.ServeMux) { + node.RegisterExplorerRoutes(mux, explorerQuery, routeFlags) + node.RegisterRelayRoutes(mux, relayConfig) + + // POST /api/governance/link — link deployed contracts at runtime. + // Body: {"governance": ""} + // Allows deploy script to wire governance without node restart. + mux.HandleFunc("/api/governance/link", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + var body struct { + Governance string `json:"governance"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, `{"error":"bad JSON"}`, http.StatusBadRequest) + return + } + if body.Governance != "" { + chain.SetGovernanceContract(body.Governance) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status":"ok","governance":%q}`, body.Governance) + }) + }); err != nil { + log.Printf("[NODE] stats server error: %v", err) + } + }() + + log.Printf("[NODE] running — peer ID: %s", h.PeerID()) + log.Printf("[NODE] addrs: %v", h.AddrStrings()) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + log.Println("[NODE] shutting down...") +} + +// emitContractLogsViaBus scans b's CALL_CONTRACT transactions and emits a +// contract_log event for each log entry through the event bus. Bus fans +// it out to every registered consumer (SSE, WS, future indexers) — no +// hub-by-hub emit calls here. +func emitContractLogsViaBus(bus *node.EventBus, chain *blockchain.Chain, b *blockchain.Block) { + for _, tx := range b.Transactions { + if tx.Type != blockchain.EventCallContract { + continue + } + var p blockchain.CallContractPayload + if err := json.Unmarshal(tx.Payload, &p); err != nil || p.ContractID == "" { + continue + } + // Fetch only logs from this block (limit=50 to avoid loading all history). + logs, err := chain.ContractLogs(p.ContractID, 50) + if err != nil { + continue + } + for _, entry := range logs { + if entry.BlockHeight == b.Index && entry.TxID == tx.ID { + bus.EmitContractLog(entry) + } + } + } +} + +// heartbeatLoop publishes a HEARTBEAT transaction once per hour. +func heartbeatLoop(ctx context.Context, id *identity.Identity, chain *blockchain.Chain, h *p2p.Host, stats *node.Tracker) { + ticker := time.NewTicker(60 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + bal, err := chain.Balance(id.PubKeyHex()) + if err != nil { + log.Printf("[NODE] heartbeat balance check failed: %v", err) + continue + } + if bal < heartbeatFeeUT { + log.Printf("[NODE] heartbeat skipped: balance %s below required fee %s", + economy.FormatTokens(bal), economy.FormatTokens(heartbeatFeeUT)) + continue + } + + var height uint64 + if tip := chain.Tip(); tip != nil { + height = tip.Index + } + payload := blockchain.HeartbeatPayload{ + PubKey: id.PubKeyHex(), + ChainHeight: height, + PeerCount: h.PeerCount(), + Version: "0.1.0", + } + pb, _ := json.Marshal(payload) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("hb-%s-%d", id.PubKeyHex()[:8], time.Now().Unix()), + Type: blockchain.EventHeartbeat, + From: id.PubKeyHex(), + Amount: 0, + Payload: pb, + Fee: heartbeatFeeUT, + Memo: "Heartbeat: liveness proof", + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(identity.TxSignBytes(tx)) + + if err := h.PublishTx(tx); err != nil { + log.Printf("[NODE] heartbeat publish: %v", err) + } else { + stats.TxsGossipSent.Add(1) + log.Printf("[NODE] heartbeat sent (height=%d peers=%d)", height, h.PeerCount()) + } + } + } +} + +// autoBindWallet submits a BIND_WALLET tx if the node doesn't have one yet. +func autoBindWallet(ctx context.Context, id *identity.Identity, w *wallet.Wallet, chain *blockchain.Chain, engine *consensus.Engine) { + // Wait a bit for chain to sync + time.Sleep(5 * time.Second) + + binding, err := chain.WalletBinding(id.PubKeyHex()) + if err != nil || binding == w.ID.PubKeyHex() { + return // already bound or error + } + + payload := blockchain.BindWalletPayload{ + WalletPubKey: w.ID.PubKeyHex(), + WalletAddr: w.Address, + } + pb, _ := json.Marshal(payload) + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("bind-%d", time.Now().UnixNano()), + Type: blockchain.EventBindWallet, + From: id.PubKeyHex(), + To: w.ID.PubKeyHex(), + Payload: pb, + Fee: blockchain.MinFee, + Memo: "Bind payout wallet", + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(identity.TxSignBytes(tx)) + + if err := engine.AddTransaction(tx); err != nil { + log.Printf("[NODE] BIND_WALLET tx rejected: %v", err) + return + } + log.Printf("[NODE] queued BIND_WALLET → %s", w.Address) +} + +// startGenesisVerifier watches the local chain and aborts the node if, once +// block 0 is present, its hash differs from what the --join seed advertised. +// +// Lifecycle: +// - Polls every 500 ms for up to 2 minutes waiting for sync to produce +// block 0. On a fresh-db joiner this may take tens of seconds while +// SyncFromPeerFull streams blocks. +// - Once the block exists, compares HashHex() with `expected`: +// match → log success, exit goroutine +// mismatch → log.Fatal, killing the process (unless `allowMismatch` +// is set, in which case we just warn and return) +// - If the window expires without a genesis, we assume something went +// wrong with peer connectivity and abort — otherwise PBFT would +// helpfully produce its OWN genesis, permanently forking us from the +// target network. +func startGenesisVerifier(ctx context.Context, chain *blockchain.Chain, expected string, allowMismatch bool) { + deadline := time.NewTimer(2 * time.Minute) + defer deadline.Stop() + tick := time.NewTicker(500 * time.Millisecond) + defer tick.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-deadline.C: + if allowMismatch { + log.Printf("[NODE] genesis verifier: no genesis after 2m — giving up (allow-genesis-mismatch set)") + return + } + log.Fatalf("[NODE] genesis verifier: could not load block 0 within 2 minutes; seed expected hash %s. Check network connectivity to peers.", expected) + case <-tick.C: + g, err := chain.GetBlock(0) + if err != nil || g == nil { + continue // still syncing + } + actual := g.HashHex() + if actual == expected { + log.Printf("[NODE] genesis verified: hash %s matches seed", actual) + return + } + if allowMismatch { + log.Printf("[NODE] WARNING: genesis hash mismatch (local=%s seed=%s) — continuing because --allow-genesis-mismatch is set", actual, expected) + return + } + log.Fatalf("[NODE] FATAL: genesis hash mismatch — local=%s seed=%s. Either the seed at --join is on a different chain, or your local DB was created against a different genesis. Wipe --db and retry, or pass --allow-genesis-mismatch if you know what you're doing.", + actual, expected) + } + } +} + +// syncOnConnect syncs chain from a newly connected peer. +// engineSyncFn is called with next seqNum after a successful sync so the +// consensus engine knows which block to propose/commit next. +func syncOnConnect(ctx context.Context, h *p2p.Host, chain *blockchain.Chain, stats *node.Tracker, pid libp2ppeer.ID, engineSyncFn func(uint64)) { + time.Sleep(500 * time.Millisecond) + syncCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + var localCount uint64 + if tip := chain.Tip(); tip != nil { + localCount = tip.Index + 1 + } + + n, err := h.SyncFromPeerFull(syncCtx, pid, localCount, func(b *blockchain.Block) error { + if tip := chain.Tip(); tip != nil && b.Index <= tip.Index { + return nil + } + if err := chain.AddBlock(b); err != nil { + return err + } + log.Printf("[SYNC] applied block #%d hash=%s", b.Index, b.HashHex()[:8]) + return nil + }) + if err != nil { + log.Printf("[SYNC] sync from %s: %v", pid, err) + return + } + if n > 0 { + stats.RecordSyncFrom(pid, n) + if tip := chain.Tip(); tip != nil { + engineSyncFn(tip.Index + 1) + log.Printf("[SYNC] synced %d blocks from %s, chain now at #%d", n, pid, tip.Index) + } + } else if localCount == 0 { + // No new blocks but we confirmed our genesis is current — still notify engine + if tip := chain.Tip(); tip != nil { + engineSyncFn(tip.Index + 1) + } + } +} + +// connectWithRetry dials a bootstrap peer and keeps the connection alive. +// +// Behaviour: +// - Retries indefinitely with exponential backoff (1 s → 2 s → … → 30 s max). +// - After a successful connection it re-dials every 30 s. +// libp2p.Connect is idempotent — if the peer is still connected the call +// returns immediately. If it dropped, this transparently reconnects it. +// - Stops only when ctx is cancelled (node shutdown). +// +// This ensures that even if a bootstrap peer restarts or is temporarily +// unreachable the node will reconnect without manual intervention. +func connectWithRetry(ctx context.Context, h *p2p.Host, addr string) { + const ( + minBackoff = 1 * time.Second + maxBackoff = 30 * time.Second + keepAlive = 30 * time.Second + ) + backoff := minBackoff + for { + select { + case <-ctx.Done(): + return + default: + } + if err := h.Connect(ctx, addr); err != nil { + log.Printf("[P2P] bootstrap %s unavailable: %v (retry in %s)", addr, err, backoff) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < maxBackoff { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + continue + } + // Connected (or already connected). Log only on first connect or reconnect. + if backoff > minBackoff { + log.Printf("[P2P] reconnected to bootstrap %s", addr) + } else { + log.Printf("[P2P] connected to bootstrap %s", addr) + } + backoff = minBackoff // reset for next reconnection cycle + // Keep-alive: re-check every 30 s so we detect and repair drops. + select { + case <-ctx.Done(): + return + case <-time.After(keepAlive): + } + } +} + +// --- helpers --- + +type keyJSON struct { + PubKey string `json:"pub_key"` + PrivKey string `json:"priv_key"` + X25519Pub string `json:"x25519_pub,omitempty"` + X25519Priv string `json:"x25519_priv,omitempty"` +} + +func loadOrCreateIdentity(keyFile string) *identity.Identity { + if data, err := os.ReadFile(keyFile); err == nil { + var kj keyJSON + if err := json.Unmarshal(data, &kj); err == nil { + if id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv); err == nil { + // If the file is missing X25519 keys, backfill and re-save. + if kj.X25519Pub == "" { + kj.X25519Pub = id.X25519PubHex() + kj.X25519Priv = id.X25519PrivHex() + if out, err2 := json.MarshalIndent(kj, "", " "); err2 == nil { + _ = os.WriteFile(keyFile, out, 0600) + } + } + log.Printf("[NODE] loaded identity from %s", keyFile) + return id + } + } + } + id, err := identity.Generate() + if err != nil { + log.Fatalf("generate identity: %v", err) + } + kj := keyJSON{ + PubKey: id.PubKeyHex(), + PrivKey: id.PrivKeyHex(), + X25519Pub: id.X25519PubHex(), + X25519Priv: id.X25519PrivHex(), + } + data, _ := json.MarshalIndent(kj, "", " ") + if err := os.WriteFile(keyFile, data, 0600); err != nil { + log.Printf("[NODE] warning: could not save key: %v", err) + } else { + log.Printf("[NODE] new identity saved to %s", keyFile) + } + return id +} + +func tipIndex(c *blockchain.Chain) uint64 { + if tip := c.Tip(); tip != nil { + return tip.Index + } + return 0 +} + +func tipHash(c *blockchain.Chain) string { + if tip := c.Tip(); tip != nil { + return tip.HashHex()[:12] + } + return "(empty)" +} + +// autoRegisterRelay submits a REGISTER_RELAY tx and retries until the registration +// is confirmed on-chain. This handles the common case where the initial gossip has +// no peers (node2 hasn't started yet) or the leader hasn't included the tx yet. +func autoRegisterRelay( + ctx context.Context, + id *identity.Identity, + relayKP *relay.KeyPair, + feePerMsgUT uint64, + addrs []string, + chain *blockchain.Chain, + engine *consensus.Engine, + h *p2p.Host, +) { + var multiaddr string + if len(addrs) > 0 { + multiaddr = addrs[0] + } + payload := blockchain.RegisterRelayPayload{ + X25519PubKey: relayKP.PubHex(), + FeePerMsgUT: feePerMsgUT, + Multiaddr: multiaddr, + } + pb, _ := json.Marshal(payload) + + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + // First attempt after 5 seconds; subsequent retries every 10 seconds. + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + + for { + // Stop if already confirmed on-chain. + if relays, err := chain.RegisteredRelays(); err == nil { + for _, r := range relays { + if r.PubKey == id.PubKeyHex() && r.Relay.X25519PubKey == relayKP.PubHex() { + log.Printf("[NODE] relay registration confirmed on-chain (x25519=%s)", relayKP.PubHex()[:16]) + return + } + } + } + + // Build and sign a fresh tx each attempt (unique ID, fresh timestamp). + now := time.Now().UTC() + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("relay-reg-%d", now.UnixNano()), + Type: blockchain.EventRegisterRelay, + From: id.PubKeyHex(), + // Pay the standard MinFee. The original code set this to 0 with the + // note "REGISTER_RELAY is free"; that's incompatible with validateTx + // in consensus/pbft.go which rejects any tx below MinFee, causing + // the auto-register retry loop to spam the logs forever without + // ever getting the tx into the mempool. + Fee: blockchain.MinFee, + Payload: pb, + Memo: "Register relay service", + Timestamp: now, + } + tx.Signature = id.Sign(identity.TxSignBytes(tx)) + + if err := engine.AddTransaction(tx); err != nil { + log.Printf("[NODE] REGISTER_RELAY tx rejected: %v", err) + } else if err := h.PublishTx(tx); err != nil { + log.Printf("[NODE] REGISTER_RELAY publish failed: %v", err) + } else { + log.Printf("[NODE] submitted REGISTER_RELAY (x25519=%s fee=%dµT), waiting for commit...", + relayKP.PubHex()[:16], feePerMsgUT) + } + + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func shortKeys(keys []string) []string { + out := make([]string, len(keys)) + for i, k := range keys { + if len(k) > 8 { + out[i] = k[:8] + "…" + } else { + out[i] = k + } + } + return out +} + +// setupLogging initialises the slog handler and routes Go's std log through +// it so existing log.Printf calls get the chosen format without a +// file-by-file migration. +// +// "text" (default) is handler-default human-readable format, same as bare +// log.Printf. "json" emits one JSON object per line with `time/level/msg` +// + any key=value attrs — what Loki/ELK ingest natively. +func setupLogging(format string) { + var handler slog.Handler + switch strings.ToLower(format) { + case "json": + handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + default: + handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + } + slog.SetDefault(slog.New(handler)) + + // Reroute Go's standard log package through slog so `log.Printf("…")` + // calls elsewhere in the codebase land in the same stream/format. + log.SetFlags(0) + log.SetOutput(slogWriter{slog.Default()}) +} + +// slogWriter adapts an io.Writer-style byte stream onto slog. Each line +// becomes one Info-level record. The whole line is stored in `msg` — we +// don't try to parse out attrs; that can happen at callsite by switching +// from log.Printf to slog.Info. +type slogWriter struct{ l *slog.Logger } + +func (w slogWriter) Write(p []byte) (int, error) { + msg := strings.TrimRight(string(p), "\r\n") + w.l.Info(msg) + return len(p), nil +} + +// ─── Env-var helpers for flag defaults ────────────────────────────────────── +// Pattern: `flag.String(name, envOr("DCHAIN_X", default), help)` lets the +// same binary be driven by either CLI flags (dev) or env-vars (Docker) with +// CLI winning. Flag.Parse() is called once; by that time os.Getenv has +// already populated the fallback via these helpers. + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envBoolOr(key string, def bool) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + switch v { + case "": + return def + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + log.Printf("[NODE] warn: env %s=%q is not a valid bool, using default %v", key, v, def) + return def + } +} + +func envUint64Or(key string, def uint64) uint64 { + v := os.Getenv(key) + if v == "" { + return def + } + var out uint64 + if _, err := fmt.Sscanf(v, "%d", &out); err != nil { + log.Printf("[NODE] warn: env %s=%q is not a valid uint64, using default %d", key, v, def) + return def + } + return out +} + +// ─── Multi-seed --join support ────────────────────────────────────────────── + +// persistedSeeds is the on-disk shape of /seeds.json. Kept minimal so +// format churn doesn't break old files; unknown fields are ignored on read. +type persistedSeeds struct { + ChainID string `json:"chain_id,omitempty"` + GenesisHash string `json:"genesis_hash,omitempty"` + URLs []string `json:"urls"` + Peers string `json:"peers,omitempty"` // comma-separated multiaddrs + SavedAt string `json:"saved_at,omitempty"` +} + +// parseSeedList splits a comma-separated --join value into trimmed, non-empty +// URL entries. Preserves order so the first is tried first. +func parseSeedList(raw string) []string { + if raw == "" { + return nil + } + var out []string + for _, s := range strings.Split(raw, ",") { + if t := strings.TrimSpace(s); t != "" { + out = append(out, t) + } + } + return out +} + +// tryJoinSeeds walks the list and returns the first seedNetworkInfo that +// responded. The matching URL is returned alongside so the caller can +// persist it as the known-good at the head of the list. +func tryJoinSeeds(urls []string) (*seedNetworkInfo, string) { + for _, u := range urls { + info, err := fetchNetworkInfo(u) + if err != nil { + log.Printf("[NODE] --join: %s failed: %v (trying next)", u, err) + continue + } + return info, u + } + return nil, "" +} + +func loadSeedsFile(path string) (*persistedSeeds, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var p persistedSeeds + if err := json.Unmarshal(b, &p); err != nil { + return nil, err + } + return &p, nil +} + +func saveSeedsFile(path string, p *persistedSeeds) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, b, 0o600) // 0600: may contain private IPs of peers +} + +// seedNetworkInfo is a minimal subset of /api/network-info needed for +// bootstrap. We intentionally DO NOT import the node package's response +// types here to keep the struct stable across schema evolution; missing +// fields simply unmarshal to zero values. +type seedNetworkInfo struct { + ChainID string `json:"chain_id"` + GenesisHash string `json:"genesis_hash"` + TipHeight uint64 `json:"tip_height"` + Validators []string `json:"validators"` + Peers []struct { + ID string `json:"id"` + Addrs []string `json:"addrs"` + } `json:"peers"` + Contracts map[string]struct { + ContractID string `json:"contract_id"` + Name string `json:"name"` + Version string `json:"version"` + } `json:"contracts"` +} + +// fetchNetworkInfo performs a GET /api/network-info on the given HTTP base +// URL (e.g. "http://seed.example:8080") and returns a parsed seedNetworkInfo. +// Times out after 10s so a misconfigured --join URL fails fast instead of +// hanging node startup. +func fetchNetworkInfo(baseURL string) (*seedNetworkInfo, error) { + baseURL = strings.TrimRight(baseURL, "/") + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(baseURL + "/api/network-info") + if err != nil { + return nil, fmt.Errorf("fetch: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + var info seedNetworkInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return &info, nil +} diff --git a/cmd/peerid/main.go b/cmd/peerid/main.go new file mode 100644 index 0000000..d826752 --- /dev/null +++ b/cmd/peerid/main.go @@ -0,0 +1,65 @@ +// cmd/peerid — prints the libp2p peer ID for a key file. +// Usage: peerid --key node1.json +package main + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "os" + + libp2pcrypto "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" +) + +type keyJSON struct { + PubKey string `json:"pub_key"` + PrivKey string `json:"priv_key"` +} + +func main() { + keyFile := flag.String("key", "", "path to key JSON file") + listenIP := flag.String("ip", "0.0.0.0", "IP for multiaddr output") + port := flag.String("port", "4001", "port for multiaddr output") + flag.Parse() + + if *keyFile == "" { + log.Fatal("--key is required") + } + + data, err := os.ReadFile(*keyFile) + if err != nil { + log.Fatalf("read key: %v", err) + } + var kj keyJSON + if err := json.Unmarshal(data, &kj); err != nil { + log.Fatalf("parse key: %v", err) + } + + privBytes, err := hexDecode(kj.PrivKey) + if err != nil { + log.Fatalf("decode priv key: %v", err) + } + + privStd := ed25519.PrivateKey(privBytes) + lk, _, err := libp2pcrypto.KeyPairFromStdKey(&privStd) + if err != nil { + log.Fatalf("convert key: %v", err) + } + + pid, err := peer.IDFromPrivateKey(lk) + if err != nil { + log.Fatalf("peer ID: %v", err) + } + + fmt.Printf("pub_key: %s\n", kj.PubKey) + fmt.Printf("peer_id: %s\n", pid) + fmt.Printf("multiaddr: /ip4/%s/tcp/%s/p2p/%s\n", *listenIP, *port, pid) +} + +func hexDecode(s string) ([]byte, error) { + return hex.DecodeString(s) +} diff --git a/cmd/wallet/main.go b/cmd/wallet/main.go new file mode 100644 index 0000000..0b68e82 --- /dev/null +++ b/cmd/wallet/main.go @@ -0,0 +1,240 @@ +// cmd/wallet — wallet management CLI. +// +// Commands: +// +// wallet create --type node|user --label --out [--pass ] +// wallet info --wallet [--pass ] +// wallet balance --wallet [--pass ] --db +// wallet bind --wallet [--pass ] --node-key +// Build a BIND_WALLET transaction to link a node to this wallet. +// Print the tx JSON (broadcast separately). +// wallet address --pub-key Derive DC address from any pub key +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + "time" + + "go-blockchain/blockchain" + "go-blockchain/economy" + "go-blockchain/identity" + "go-blockchain/wallet" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + switch os.Args[1] { + case "create": + cmdCreate(os.Args[2:]) + case "info": + cmdInfo(os.Args[2:]) + case "balance": + cmdBalance(os.Args[2:]) + case "bind": + cmdBind(os.Args[2:]) + case "address": + cmdAddress(os.Args[2:]) + default: + usage() + os.Exit(1) + } +} + +func usage() { + fmt.Print(`wallet — manage DC wallets + +Commands: + create --type node|user --label --out [--pass ] + info --wallet [--pass ] + balance --wallet [--pass ] --db + bind --wallet [--pass ] --node-key + address --pub-key +`) +} + +func cmdCreate(args []string) { + fs := flag.NewFlagSet("create", flag.ExitOnError) + wtype := fs.String("type", "user", "wallet type: node or user") + label := fs.String("label", "My Wallet", "wallet label") + out := fs.String("out", "wallet.json", "output file") + pass := fs.String("pass", "", "encryption passphrase (empty = no encryption)") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + wt := wallet.UserWallet + if *wtype == "node" { + wt = wallet.NodeWallet + } + + w, err := wallet.New(wt, *label) + if err != nil { + log.Fatalf("create wallet: %v", err) + } + + if err := w.Save(*out, *pass); err != nil { + log.Fatalf("save wallet: %v", err) + } + + fmt.Printf("Wallet created:\n") + fmt.Printf(" type: %s\n", w.Type) + fmt.Printf(" label: %s\n", w.Label) + fmt.Printf(" address: %s\n", w.Address) + fmt.Printf(" pub_key: %s\n", w.ID.PubKeyHex()) + fmt.Printf(" saved: %s\n", *out) + if *pass == "" { + fmt.Println(" warning: no passphrase set — private key is unencrypted!") + } +} + +func cmdInfo(args []string) { + fs := flag.NewFlagSet("info", flag.ExitOnError) + file := fs.String("wallet", "wallet.json", "wallet file") + pass := fs.String("pass", "", "passphrase") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + w, err := wallet.Load(*file, *pass) + if err != nil { + log.Fatalf("load wallet: %v", err) + } + + data, _ := json.MarshalIndent(w.Info(), "", " ") + fmt.Println(string(data)) +} + +func cmdBalance(args []string) { + fs := flag.NewFlagSet("balance", flag.ExitOnError) + file := fs.String("wallet", "wallet.json", "wallet file") + pass := fs.String("pass", "", "passphrase") + dbPath := fs.String("db", "./chaindata", "chain DB path") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + w, err := wallet.Load(*file, *pass) + if err != nil { + log.Fatalf("load wallet: %v", err) + } + + chain, err := blockchain.NewChain(*dbPath) + if err != nil { + log.Fatalf("open chain: %v", err) + } + defer chain.Close() + + bal, err := chain.Balance(w.ID.PubKeyHex()) + if err != nil { + log.Fatalf("query balance: %v", err) + } + + rep, err := chain.Reputation(w.ID.PubKeyHex()) + if err != nil { + log.Printf("reputation unavailable: %v", err) + } + + binding, _ := chain.WalletBinding(w.ID.PubKeyHex()) + + fmt.Printf("Wallet: %s\n", w.Short()) + fmt.Printf(" Address: %s\n", w.Address) + fmt.Printf(" Pub key: %s\n", w.ID.PubKeyHex()) + fmt.Printf(" Balance: %s (%d µT)\n", economy.FormatTokens(bal), bal) + fmt.Printf(" Reputation: score=%d rank=%s (blocks=%d relay=%d slashes=%d)\n", + rep.Score, rep.Rank(), rep.BlocksProduced, rep.RelayProofs, rep.SlashCount) + if binding != "" { + fmt.Printf(" Wallet binding: → %s\n", wallet.PubKeyToAddress(binding)) + } else if w.Type == wallet.NodeWallet { + fmt.Printf(" Wallet binding: (none — rewards go to node key itself)\n") + } +} + +func cmdBind(args []string) { + fs := flag.NewFlagSet("bind", flag.ExitOnError) + file := fs.String("wallet", "wallet.json", "payout wallet file") + pass := fs.String("pass", "", "wallet passphrase") + nodeKeyFile := fs.String("node-key", "node.json", "node identity JSON file") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + // Load payout wallet (where rewards should go) + w, err := wallet.Load(*file, *pass) + if err != nil { + log.Fatalf("load wallet: %v", err) + } + + // Load node identity (the one that signs blocks) + type rawKey struct { + PubKey string `json:"pub_key"` + PrivKey string `json:"priv_key"` + } + raw, err := os.ReadFile(*nodeKeyFile) + if err != nil { + log.Fatalf("read node key: %v", err) + } + var rk rawKey + if err := json.Unmarshal(raw, &rk); err != nil { + log.Fatalf("parse node key: %v", err) + } + nodeID, err := identity.FromHex(rk.PubKey, rk.PrivKey) + if err != nil { + log.Fatalf("load node identity: %v", err) + } + + // Build BIND_WALLET transaction signed by the node key + payload := blockchain.BindWalletPayload{ + WalletPubKey: w.ID.PubKeyHex(), + WalletAddr: w.Address, + } + payloadBytes, _ := json.Marshal(payload) + + tx := &blockchain.Transaction{ + ID: fmt.Sprintf("bind-%d", time.Now().UnixNano()), + Type: blockchain.EventBindWallet, + From: nodeID.PubKeyHex(), + To: w.ID.PubKeyHex(), + Payload: payloadBytes, + Fee: blockchain.MinFee, + Timestamp: time.Now().UTC(), + } + // Sign with the node key (node authorises the binding) + signBytes, _ := json.Marshal(struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + From string `json:"from"` + To string `json:"to"` + Amount uint64 `json:"amount"` + Fee uint64 `json:"fee"` + Payload []byte `json:"payload"` + Timestamp time.Time `json:"timestamp"` + }{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp}) + tx.Signature = nodeID.Sign(signBytes) + + data, _ := json.MarshalIndent(tx, "", " ") + fmt.Printf("BIND_WALLET transaction (broadcast to a node to commit):\n\n%s\n\n", string(data)) + fmt.Printf("Effect: node %s...%s will pay rewards to wallet %s\n", + nodeID.PubKeyHex()[:8], nodeID.PubKeyHex()[len(nodeID.PubKeyHex())-4:], + w.Address) +} + +func cmdAddress(args []string) { + fs := flag.NewFlagSet("address", flag.ExitOnError) + pubKey := fs.String("pub-key", "", "hex-encoded Ed25519 public key") + if err := fs.Parse(args); err != nil { + log.Fatal(err) + } + + if *pubKey == "" { + log.Fatal("--pub-key is required") + } + addr := wallet.PubKeyToAddress(*pubKey) + fmt.Printf("pub_key: %s\naddress: %s\n", *pubKey, addr) +} diff --git a/consensus/pbft.go b/consensus/pbft.go new file mode 100644 index 0000000..bf34366 --- /dev/null +++ b/consensus/pbft.go @@ -0,0 +1,883 @@ +// Package consensus implements PBFT (Practical Byzantine Fault Tolerance) +// in the Tendermint style: Pre-prepare → Prepare → Commit. +// +// Safety: block committed only after 2f+1 COMMIT votes (f = max faulty nodes) +// Liveness: view-change if no commit within blockTimeout +package consensus + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "go-blockchain/blockchain" + "go-blockchain/identity" +) + +const ( + phaseNone = 0 + phasePrepare = 1 // received PRE-PREPARE, broadcast PREPARE + phaseCommit = 2 // have 2f+1 PREPARE, broadcast COMMIT + + blockTimeout = 2 * time.Second +) + +// CommitCallback is called when a block reaches 2f+1 COMMIT votes. +type CommitCallback func(block *blockchain.Block) + +// Engine is a single-node PBFT consensus engine. +type Engine struct { + mu sync.Mutex + + id *identity.Identity + validators []string // sorted hex pub keys of all validators + + view uint64 // current PBFT view (increments on view-change) + seqNum uint64 // index of the next block we expect to propose/commit + + // in-flight round state + phase int + proposal *blockchain.Block + + prepareVotes map[string]bool + commitVotes map[string]bool + + onCommit CommitCallback + + // send broadcasts a ConsensusMsg to all peers (via P2P layer). + send func(msg *blockchain.ConsensusMsg) + + // liveness tracks the last seqNum we saw a commit vote from each + // validator. Used by LivenessReport to surface stale peers in /metrics + // and logs. In-memory only — restarting the node resets counters, + // which is fine because auto-removal requires multiple nodes to + // independently agree anyway. + livenessMu sync.RWMutex + liveness map[string]uint64 // pubkey → last seqNum + + // seenVotes records the first PREPARE/COMMIT we saw from each validator + // for each (type, view, seqNum). If a second message arrives with a + // different BlockHash, it's equivocation evidence — we stash both so + // an operator (or future auto-slasher) can submit a SLASH tx. + // Bounded implicitly: entries are pruned as we advance past the seqNum. + evidenceMu sync.Mutex + seenVotes map[voteKey]*blockchain.ConsensusMsg + pendingEvidence []blockchain.EquivocationEvidence + + // Mempool is fair-queued per sender: each address has its own FIFO + // queue and Propose drains them round-robin. Without this, one + // spammer's txs go in first and can starve everyone else for the + // duration of the flood. + // + // senderQueues[from] — per-address FIFO of uncommitted txs + // senderOrder — iteration order for round-robin draining + // seenIDs — O(1) dedup set across all queues + pendingMu sync.Mutex + senderQueues map[string][]*blockchain.Transaction + senderOrder []string + seenIDs map[string]struct{} + + timer *time.Timer + + // optional stats hooks — called outside the lock + hookPropose func() + hookVote func() + hookViewChange func() +} + +// OnPropose registers a hook called each time this node proposes a block. +func (e *Engine) OnPropose(fn func()) { e.hookPropose = fn } + +// OnVote registers a hook called each time this node casts a PREPARE or COMMIT vote. +func (e *Engine) OnVote(fn func()) { e.hookVote = fn } + +// OnViewChange registers a hook called each time a view-change is triggered. +func (e *Engine) OnViewChange(fn func()) { e.hookViewChange = fn } + +// NewEngine creates a PBFT engine. +// - validators: complete validator set (including this node) +// - seqNum: tip.Index + 1 (or 0 if chain is empty) +// - onCommit: called when a block is finalised +// - send: broadcast function (gossipsub publish) +func NewEngine( + id *identity.Identity, + validators []string, + seqNum uint64, + onCommit CommitCallback, + send func(*blockchain.ConsensusMsg), +) *Engine { + return &Engine{ + id: id, + validators: validators, + seqNum: seqNum, + onCommit: onCommit, + send: send, + senderQueues: make(map[string][]*blockchain.Transaction), + seenIDs: make(map[string]struct{}), + liveness: make(map[string]uint64), + seenVotes: make(map[voteKey]*blockchain.ConsensusMsg), + } +} + +// recordVote stores the vote and checks whether it conflicts with a prior +// vote from the same validator at the same consensus position. If so, it +// pushes the pair onto pendingEvidence for later retrieval via +// TakeEvidence. +// +// Only PREPARE and COMMIT are checked. PRE-PREPARE equivocation can happen +// legitimately during view changes, so we don't flag it. +func (e *Engine) recordVote(msg *blockchain.ConsensusMsg) { + if msg.Type != blockchain.MsgPrepare && msg.Type != blockchain.MsgCommit { + return + } + k := voteKey{from: msg.From, typ: msg.Type, view: msg.View, seqNum: msg.SeqNum} + + e.evidenceMu.Lock() + defer e.evidenceMu.Unlock() + prev, seen := e.seenVotes[k] + if !seen { + // First message at this position from this validator — record. + msgCopy := *msg + e.seenVotes[k] = &msgCopy + return + } + if bytesEqualBlockHash(prev.BlockHash, msg.BlockHash) { + return // same vote, not equivocation + } + // Equivocation detected. + log.Printf("[PBFT] EQUIVOCATION: %s signed two %v at view=%d seq=%d (blocks %x vs %x)", + shortKey(msg.From), msg.Type, msg.View, msg.SeqNum, + prev.BlockHash[:min(4, len(prev.BlockHash))], + msg.BlockHash[:min(4, len(msg.BlockHash))]) + msgCopy := *msg + e.pendingEvidence = append(e.pendingEvidence, blockchain.EquivocationEvidence{ + A: prev, + B: &msgCopy, + }) +} + +// TakeEvidence drains the collected equivocation evidence. Caller is +// responsible for deciding what to do with it (typically: wrap each into +// a SLASH tx and submit). Safe to call concurrently. +func (e *Engine) TakeEvidence() []blockchain.EquivocationEvidence { + e.evidenceMu.Lock() + defer e.evidenceMu.Unlock() + if len(e.pendingEvidence) == 0 { + return nil + } + out := e.pendingEvidence + e.pendingEvidence = nil + return out +} + +// pruneOldVotes clears seenVotes entries below the given seqNum floor so +// memory doesn't grow unboundedly. Called after each commit. +func (e *Engine) pruneOldVotes(belowSeq uint64) { + e.evidenceMu.Lock() + defer e.evidenceMu.Unlock() + for k := range e.seenVotes { + if k.seqNum < belowSeq { + delete(e.seenVotes, k) + } + } +} + +func bytesEqualBlockHash(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// MissedBlocks returns how many seqNums have passed since the given validator +// last contributed a commit vote. A missing entry is treated as "never seen", +// in which case we return the current seqNum — caller can decide what to do. +// +// Thread-safe; may be polled from a metrics/reporting goroutine. +func (e *Engine) MissedBlocks(pubKey string) uint64 { + e.livenessMu.RLock() + lastSeen, ok := e.liveness[pubKey] + e.livenessMu.RUnlock() + + e.mu.Lock() + cur := e.seqNum + e.mu.Unlock() + + if !ok { + return cur + } + if cur <= lastSeen { + return 0 + } + return cur - lastSeen +} + +// LivenessReport returns a snapshot of (validator, missedBlocks) for the +// full current set. Intended for the /metrics endpoint and ops dashboards. +func (e *Engine) LivenessReport() map[string]uint64 { + e.mu.Lock() + vals := make([]string, len(e.validators)) + copy(vals, e.validators) + e.mu.Unlock() + + out := make(map[string]uint64, len(vals)) + for _, v := range vals { + out[v] = e.MissedBlocks(v) + } + return out +} + +// noteLiveness records that `pubKey` contributed a commit at `seq`. +// Called from handleCommit whenever we see a matching vote. +func (e *Engine) noteLiveness(pubKey string, seq uint64) { + e.livenessMu.Lock() + if seq > e.liveness[pubKey] { + e.liveness[pubKey] = seq + } + e.livenessMu.Unlock() +} + +// voteKey uniquely identifies a (validator, phase, round) tuple. Two +// messages sharing a voteKey but with different BlockHash are equivocation. +type voteKey struct { + from string + typ blockchain.MsgType + view uint64 + seqNum uint64 +} + +// MaxTxsPerBlock caps how many transactions one proposal pulls from the +// mempool. Keeps block commit time bounded regardless of pending backlog. +// Combined with the round-robin drain, this also caps how many txs a +// single sender can get into one block to `ceil(MaxTxsPerBlock / senders)`. +const MaxTxsPerBlock = 200 + +// UpdateValidators hot-reloads the validator set. Safe to call concurrently. +// The new set takes effect on the next round (does not affect the current in-flight round). +func (e *Engine) UpdateValidators(validators []string) { + e.mu.Lock() + defer e.mu.Unlock() + e.validators = validators + log.Printf("[PBFT] validator set updated: %d validators", len(validators)) +} + +// SyncSeqNum updates the engine's expected next block index after a chain sync. +func (e *Engine) SyncSeqNum(next uint64) { + e.mu.Lock() + defer e.mu.Unlock() + if next > e.seqNum { + e.seqNum = next + e.phase = phaseNone + e.reclaimProposal() + e.proposal = nil + } +} + +// AddTransaction validates and adds a tx to the pending mempool. +// Returns an error if the tx is invalid or a duplicate; the tx is silently +// dropped in both cases so callers can safely ignore the return value when +// forwarding gossip from untrusted peers. +func (e *Engine) AddTransaction(tx *blockchain.Transaction) error { + if err := validateTx(tx); err != nil { + return err + } + + e.pendingMu.Lock() + defer e.pendingMu.Unlock() + + // O(1) dedup across all per-sender queues. + if _, seen := e.seenIDs[tx.ID]; seen { + return fmt.Errorf("duplicate tx: %s", tx.ID) + } + + // Route by sender. First tx from a new address extends the round-robin + // iteration order so later senders don't starve earlier ones. + if _, ok := e.senderQueues[tx.From]; !ok { + e.senderOrder = append(e.senderOrder, tx.From) + } + e.senderQueues[tx.From] = append(e.senderQueues[tx.From], tx) + e.seenIDs[tx.ID] = struct{}{} + return nil +} + +// validateTx performs stateless transaction validation: +// - required fields present +// - fee at or above MinFee +// - Ed25519 signature valid over canonical bytes +func validateTx(tx *blockchain.Transaction) error { + if tx == nil { + return fmt.Errorf("nil transaction") + } + if tx.ID == "" || tx.From == "" || tx.Type == "" { + return fmt.Errorf("tx missing required fields (id/from/type)") + } + if tx.Fee < blockchain.MinFee { + return fmt.Errorf("tx fee %d < MinFee %d", tx.Fee, blockchain.MinFee) + } + // Delegate signature verification to identity.VerifyTx so that the + // canonical signing bytes are defined in exactly one place. + if err := identity.VerifyTx(tx); err != nil { + return fmt.Errorf("tx signature invalid: %w", err) + } + return nil +} + +// requeueHead puts txs back at the FRONT of their sender's FIFO. Used when +// Propose aborted after draining (chain tip advanced under us) so the txs +// don't get moved to the back of the line through no fault of the sender. +// Preserves per-sender ordering within the given slice. +func (e *Engine) requeueHead(txs []*blockchain.Transaction) { + if len(txs) == 0 { + return + } + // Group by sender to preserve per-sender order. + bySender := make(map[string][]*blockchain.Transaction) + order := []string{} + for _, tx := range txs { + if _, ok := bySender[tx.From]; !ok { + order = append(order, tx.From) + } + bySender[tx.From] = append(bySender[tx.From], tx) + } + e.pendingMu.Lock() + defer e.pendingMu.Unlock() + for _, sender := range order { + if _, known := e.senderQueues[sender]; !known { + e.senderOrder = append(e.senderOrder, sender) + } + // Prepend: new slice = group + existing. + e.senderQueues[sender] = append(bySender[sender], e.senderQueues[sender]...) + } +} + +// requeueTail puts txs at the BACK of their sender's FIFO, skipping any +// that are already seen. Used on view-change rescue — the tx has been in +// flight for a while and fairness dictates it shouldn't jump ahead of txs +// that arrived since. Returns the count actually requeued. +func (e *Engine) requeueTail(txs []*blockchain.Transaction) int { + e.pendingMu.Lock() + defer e.pendingMu.Unlock() + rescued := 0 + for _, tx := range txs { + if _, seen := e.seenIDs[tx.ID]; seen { + continue + } + if _, ok := e.senderQueues[tx.From]; !ok { + e.senderOrder = append(e.senderOrder, tx.From) + } + e.senderQueues[tx.From] = append(e.senderQueues[tx.From], tx) + e.seenIDs[tx.ID] = struct{}{} + rescued++ + } + return rescued +} + +// HasPendingTxs reports whether there are uncommitted transactions in the mempool. +// Used by the block-production loop to skip proposals when there is nothing to commit. +func (e *Engine) HasPendingTxs() bool { + e.pendingMu.Lock() + defer e.pendingMu.Unlock() + for _, q := range e.senderQueues { + if len(q) > 0 { + return true + } + } + return false +} + +// PruneTxs removes transactions that were committed in a block from the pending +// mempool. Must be called by the onCommit handler so that non-proposing validators +// don't re-propose transactions they received via gossip but didn't drain themselves. +func (e *Engine) PruneTxs(txs []*blockchain.Transaction) { + if len(txs) == 0 { + return + } + committed := make(map[string]bool, len(txs)) + for _, tx := range txs { + committed[tx.ID] = true + } + e.pendingMu.Lock() + defer e.pendingMu.Unlock() + for sender, q := range e.senderQueues { + kept := q[:0] + for _, tx := range q { + if !committed[tx.ID] { + kept = append(kept, tx) + } else { + delete(e.seenIDs, tx.ID) + } + } + if len(kept) == 0 { + delete(e.senderQueues, sender) + } else { + e.senderQueues[sender] = kept + } + } + // Prune senderOrder of now-empty senders so iteration stays O(senders). + if len(e.senderOrder) > 0 { + pruned := e.senderOrder[:0] + for _, s := range e.senderOrder { + if _, ok := e.senderQueues[s]; ok { + pruned = append(pruned, s) + } + } + e.senderOrder = pruned + } +} + +// IsLeader returns true if this node is leader for the current round. +// Leadership rotates: leader = validators[(seqNum + view) % n] +func (e *Engine) IsLeader() bool { + if len(e.validators) == 0 { + return false + } + idx := int(e.seqNum+e.view) % len(e.validators) + return e.validators[idx] == e.id.PubKeyHex() +} + +// Propose builds a block and broadcasts a PRE-PREPARE message. +// Only the current leader calls this. +func (e *Engine) Propose(prevBlock *blockchain.Block) { + e.mu.Lock() + defer e.mu.Unlock() + + if !e.IsLeader() || e.phase != phaseNone { + return + } + + // Round-robin drain: take one tx from each sender's FIFO per pass, + // up to MaxTxsPerBlock. Guarantees that a spammer's 10k-tx queue can + // not starve a legitimate user who has just one tx pending. + e.pendingMu.Lock() + txs := make([]*blockchain.Transaction, 0, MaxTxsPerBlock) + for len(txs) < MaxTxsPerBlock { + drained := 0 + for _, sender := range e.senderOrder { + q := e.senderQueues[sender] + if len(q) == 0 { + continue + } + txs = append(txs, q[0]) + // Pop the head of this sender's queue. + q = q[1:] + if len(q) == 0 { + delete(e.senderQueues, sender) + } else { + e.senderQueues[sender] = q + } + drained++ + if len(txs) >= MaxTxsPerBlock { + break + } + } + if drained == 0 { + break // no queues had any tx this pass → done + } + } + // Rebuild senderOrder keeping only senders who still have pending txs, + // so iteration cost stays O(active senders) on next round. + if len(e.senderOrder) > 0 { + keep := e.senderOrder[:0] + for _, s := range e.senderOrder { + if _, ok := e.senderQueues[s]; ok { + keep = append(keep, s) + } + } + e.senderOrder = keep + } + // seenIDs is left intact — those txs are now in-flight; PruneTxs in + // the commit callback will clear them once accepted. + e.pendingMu.Unlock() + + var prevHash []byte + var idx uint64 + if prevBlock != nil { + prevHash = prevBlock.Hash + idx = prevBlock.Index + 1 + } + if idx != e.seqNum { + // Chain tip doesn't match our expected seqNum — return txs to mempool and wait for sync. + // requeueHead puts them back at the front of each sender's FIFO. + e.requeueHead(txs) + return + } + + var totalFees uint64 + for _, tx := range txs { + totalFees += tx.Fee + } + + b := &blockchain.Block{ + Index: idx, + Timestamp: time.Now().UTC(), + Transactions: txs, + PrevHash: prevHash, + Validator: e.id.PubKeyHex(), + TotalFees: totalFees, + } + b.ComputeHash() + b.Sign(e.id.PrivKey) + + e.proposal = b + e.prepareVotes = make(map[string]bool) + e.commitVotes = make(map[string]bool) + + // Broadcast PRE-PREPARE + e.send(e.signMsg(&blockchain.ConsensusMsg{ + Type: blockchain.MsgPrePrepare, + View: e.view, + SeqNum: b.Index, + BlockHash: b.Hash, + Block: b, + })) + + log.Printf("[PBFT] leader %s proposed block #%d hash=%s", + shortKey(e.id.PubKeyHex()), b.Index, b.HashHex()[:8]) + + if e.hookPropose != nil { + go e.hookPropose() + } + + // Leader casts its own PREPARE vote immediately + e.castPrepare() + e.resetTimer() +} + +// HandleMessage processes an incoming ConsensusMsg from a peer. +func (e *Engine) HandleMessage(msg *blockchain.ConsensusMsg) { + if err := e.verifyMsgSig(msg); err != nil { + log.Printf("[PBFT] bad sig from %s: %v", shortKey(msg.From), err) + return + } + if !e.isKnownValidator(msg.From) { + return + } + + e.mu.Lock() + defer e.mu.Unlock() + + switch msg.Type { + case blockchain.MsgPrePrepare: + e.handlePrePrepare(msg) + case blockchain.MsgPrepare: + e.handlePrepare(msg) + case blockchain.MsgCommit: + e.handleCommit(msg) + case blockchain.MsgViewChange: + e.handleViewChange(msg) + } +} + +// --- phase handlers --- + +func (e *Engine) handlePrePrepare(msg *blockchain.ConsensusMsg) { + if msg.View != e.view || msg.SeqNum != e.seqNum { + return + } + if e.phase != phaseNone || msg.Block == nil { + return + } + + // Verify that block hash matches its canonical content + msg.Block.ComputeHash() + if !hashEqual(msg.Block.Hash, msg.BlockHash) { + log.Printf("[PBFT] PRE-PREPARE: block hash mismatch") + return + } + + e.proposal = msg.Block + e.prepareVotes = make(map[string]bool) + e.commitVotes = make(map[string]bool) + + log.Printf("[PBFT] %s accepted PRE-PREPARE for block #%d", + shortKey(e.id.PubKeyHex()), msg.SeqNum) + + e.castPrepare() + e.resetTimer() +} + +// castPrepare adds own PREPARE vote and broadcasts; advances phase if quorum. +// Must be called with e.mu held. +func (e *Engine) castPrepare() { + e.phase = phasePrepare + e.prepareVotes[e.id.PubKeyHex()] = true + + if e.hookVote != nil { + go e.hookVote() + } + + e.send(e.signMsg(&blockchain.ConsensusMsg{ + Type: blockchain.MsgPrepare, + View: e.view, + SeqNum: e.proposal.Index, + BlockHash: e.proposal.Hash, + })) + + if e.quorum(len(e.prepareVotes)) { + e.advanceToCommit() + } +} + +func (e *Engine) handlePrepare(msg *blockchain.ConsensusMsg) { + // Equivocation check runs BEFORE the view/proposal filter — we want to + // catch votes for a different block even if we've already moved on. + e.recordVote(msg) + if msg.View != e.view || e.proposal == nil { + return + } + if !hashEqual(msg.BlockHash, e.proposal.Hash) { + return + } + e.prepareVotes[msg.From] = true + + if e.phase == phasePrepare && e.quorum(len(e.prepareVotes)) { + e.advanceToCommit() + } +} + +// advanceToCommit transitions to COMMIT phase and casts own COMMIT vote. +// Must be called with e.mu held. +func (e *Engine) advanceToCommit() { + e.phase = phaseCommit + e.commitVotes[e.id.PubKeyHex()] = true + // Self-liveness: we're about to broadcast COMMIT, so count ourselves + // as participating in this seqNum. Without this our own pubkey would + // always show "missed blocks = current seqNum" in LivenessReport. + e.noteLiveness(e.id.PubKeyHex(), e.seqNum) + + if e.hookVote != nil { + go e.hookVote() + } + + e.send(e.signMsg(&blockchain.ConsensusMsg{ + Type: blockchain.MsgCommit, + View: e.view, + SeqNum: e.proposal.Index, + BlockHash: e.proposal.Hash, + })) + + log.Printf("[PBFT] %s sent COMMIT for block #%d (prepare quorum %d/%d)", + shortKey(e.id.PubKeyHex()), e.proposal.Index, + len(e.prepareVotes), len(e.validators)) + + e.tryFinalize() +} + +func (e *Engine) handleCommit(msg *blockchain.ConsensusMsg) { + e.recordVote(msg) + if msg.View != e.view || e.proposal == nil { + return + } + if !hashEqual(msg.BlockHash, e.proposal.Hash) { + return + } + e.commitVotes[msg.From] = true + // Record liveness so we know which validators are still participating. + // msg.SeqNum reflects the block being committed; use it directly. + e.noteLiveness(msg.From, msg.SeqNum) + e.tryFinalize() +} + +// tryFinalize commits the block if commit quorum is reached. +// Must be called with e.mu held. +func (e *Engine) tryFinalize() { + if e.phase != phaseCommit || !e.quorum(len(e.commitVotes)) { + return + } + + committed := e.proposal + e.proposal = nil + e.phase = phaseNone + e.seqNum++ + // Drop recorded votes for previous seqNums so the equivocation- + // detection map doesn't grow unboundedly. Keep the current seqNum + // in case a late duplicate arrives. + e.pruneOldVotes(e.seqNum) + + if e.timer != nil { + e.timer.Stop() + } + + log.Printf("[PBFT] COMMITTED block #%d hash=%s validator=%s fees=%d µT (commit votes %d/%d)", + committed.Index, committed.HashHex()[:8], + shortKey(committed.Validator), + committed.TotalFees, + len(e.commitVotes), len(e.validators)) + + go e.onCommit(committed) // call outside lock +} + +func (e *Engine) handleViewChange(msg *blockchain.ConsensusMsg) { + if msg.View > e.view { + e.view = msg.View + e.phase = phaseNone + e.reclaimProposal() + e.proposal = nil + log.Printf("[PBFT] view-change to view %d (new leader: %s)", + e.view, shortKey(e.currentLeader())) + } +} + +// reclaimProposal moves transactions from the in-flight proposal back into the +// pending mempool so they are not permanently lost on a view-change or timeout. +// Must be called with e.mu held; acquires pendingMu internally. +func (e *Engine) reclaimProposal() { + if e.proposal == nil || len(e.proposal.Transactions) == 0 { + return + } + // Re-enqueue via the helper so per-sender FIFO + dedup invariants hold. + // Any tx already in-flight (still in seenIDs) is skipped; the rest land + // at the TAIL of the sender's queue — they're "older" than whatever the + // user sent since; it's a loss of order, but the alternative (HEAD + // insert) would starve later arrivals. + rescued := e.requeueTail(e.proposal.Transactions) + if rescued > 0 { + log.Printf("[PBFT] reclaimed %d tx(s) from abandoned proposal #%d back to mempool", + rescued, e.proposal.Index) + } +} + +// --- helpers --- + +func (e *Engine) quorum(count int) bool { + n := len(e.validators) + if n == 0 { + return false + } + needed := (2*n + 2) / 3 // ⌈2n/3⌉ + return count >= needed +} + +func (e *Engine) currentLeader() string { + if len(e.validators) == 0 { + return "" + } + return e.validators[int(e.seqNum+e.view)%len(e.validators)] +} + +func (e *Engine) isKnownValidator(pubKeyHex string) bool { + for _, v := range e.validators { + if v == pubKeyHex { + return true + } + } + return false +} + +func (e *Engine) resetTimer() { + if e.timer != nil { + e.timer.Stop() + } + e.timer = time.AfterFunc(blockTimeout, func() { + e.mu.Lock() + defer e.mu.Unlock() + if e.phase == phaseNone { + return + } + + // Count votes from OTHER validators (not ourselves). + // If we received zero foreign votes the peer is simply not connected yet — + // advancing the view would desync us (we'd be in view N+1, the peer in view 0). + // Instead: silently reset and let the next proposal tick retry in the same view. + otherVotes := 0 + ownKey := e.id.PubKeyHex() + if e.phase == phasePrepare { + for k := range e.prepareVotes { + if k != ownKey { + otherVotes++ + } + } + } else { // phaseCommit + for k := range e.commitVotes { + if k != ownKey { + otherVotes++ + } + } + } + if otherVotes == 0 { + // No peer participation — peer is offline/not yet connected. + // Reset without a view-change so both sides stay in view 0 + // and can agree as soon as the peer comes up. + log.Printf("[PBFT] timeout in view %d seq %d — no peer votes, retrying in same view", + e.view, e.seqNum) + e.phase = phaseNone + e.reclaimProposal() + e.proposal = nil + return + } + + // Got votes from at least one peer but still timed out — real view-change. + log.Printf("[PBFT] timeout in view %d seq %d — triggering view-change", + e.view, e.seqNum) + e.view++ + e.phase = phaseNone + e.reclaimProposal() + e.proposal = nil + if e.hookViewChange != nil { + go e.hookViewChange() + } + e.send(e.signMsg(&blockchain.ConsensusMsg{ + Type: blockchain.MsgViewChange, + View: e.view, + SeqNum: e.seqNum, + })) + }) +} + +func (e *Engine) signMsg(msg *blockchain.ConsensusMsg) *blockchain.ConsensusMsg { + msg.From = e.id.PubKeyHex() + msg.Signature = e.id.Sign(msgSignBytes(msg)) + return msg +} + +func (e *Engine) verifyMsgSig(msg *blockchain.ConsensusMsg) error { + sig := msg.Signature + msg.Signature = nil + raw := msgSignBytes(msg) + msg.Signature = sig + + ok, err := identity.Verify(msg.From, raw, sig) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("invalid signature") + } + return nil +} + +func msgSignBytes(msg *blockchain.ConsensusMsg) []byte { + tmp := *msg + tmp.Signature = nil + tmp.Block = nil // block hash covers block content + data, _ := json.Marshal(tmp) + h := sha256.Sum256(data) + return h[:] +} + +func hashEqual(a, b []byte) bool { + return hex.EncodeToString(a) == hex.EncodeToString(b) +} + +func shortKey(h string) string { + if len(h) > 8 { + return h[:8] + } + return h +} diff --git a/consensus/pbft_test.go b/consensus/pbft_test.go new file mode 100644 index 0000000..b58271b --- /dev/null +++ b/consensus/pbft_test.go @@ -0,0 +1,438 @@ +package consensus_test + +import ( + "encoding/json" + "sync" + "testing" + "time" + + "go-blockchain/blockchain" + "go-blockchain/consensus" + "go-blockchain/identity" +) + +// ─── helpers ───────────────────────────────────────────────────────────────── + +func newID(t *testing.T) *identity.Identity { + t.Helper() + id, err := identity.Generate() + if err != nil { + t.Fatalf("identity.Generate: %v", err) + } + return id +} + +func genesisFor(id *identity.Identity) *blockchain.Block { + return blockchain.GenesisBlock(id.PubKeyHex(), id.PrivKey) +} + +// network is a simple in-process message bus between engines. +// Each engine has a dedicated goroutine that delivers messages in FIFO order, +// which avoids the race where a PREPARE arrives before the PRE-PREPARE it +// depends on (which would cause the vote to be silently discarded). +type network struct { + mu sync.Mutex + queues []chan *blockchain.ConsensusMsg + engines []*consensus.Engine +} + +// addEngine registers an engine and starts its delivery goroutine. +func (n *network) addEngine(e *consensus.Engine) { + ch := make(chan *blockchain.ConsensusMsg, 256) + n.mu.Lock() + n.engines = append(n.engines, e) + n.queues = append(n.queues, ch) + n.mu.Unlock() + go func() { + for msg := range ch { + e.HandleMessage(msg) + } + }() +} + +func (n *network) broadcast(msg *blockchain.ConsensusMsg) { + n.mu.Lock() + queues := make([]chan *blockchain.ConsensusMsg, len(n.queues)) + copy(queues, n.queues) + n.mu.Unlock() + for _, q := range queues { + cp := *msg // copy to avoid concurrent signature-nil race in verifyMsgSig + q <- &cp + } +} + +// committedBlocks collects blocks committed by an engine into a channel. +type committedBlocks struct { + ch chan *blockchain.Block +} + +func (cb *committedBlocks) onCommit(b *blockchain.Block) { + cb.ch <- b +} + +func newCommitted() *committedBlocks { + return &committedBlocks{ch: make(chan *blockchain.Block, 16)} +} + +func (cb *committedBlocks) waitOne(t *testing.T, timeout time.Duration) *blockchain.Block { + t.Helper() + select { + case b := <-cb.ch: + return b + case <-time.After(timeout): + t.Fatal("timed out waiting for committed block") + return nil + } +} + +// ─── tests ─────────────────────────────────────────────────────────────────── + +// TestSingleValidatorCommit verifies that a single-validator network commits +// blocks immediately (f=0, quorum=1). +func TestSingleValidatorCommit(t *testing.T) { + id := newID(t) + genesis := genesisFor(id) + + committed := newCommitted() + net := &network{} + + engine := consensus.NewEngine( + id, + []string{id.PubKeyHex()}, + 1, // seqNum = genesis+1 + committed.onCommit, + net.broadcast, + ) + net.addEngine(engine) + + // Propose block 1 from genesis. + engine.Propose(genesis) + + b := committed.waitOne(t, 3*time.Second) + if b.Index != 1 { + t.Errorf("expected committed block index 1, got %d", b.Index) + } + if b.Validator != id.PubKeyHex() { + t.Errorf("wrong validator in committed block") + } +} + +// TestSingleValidatorMultipleBlocks verifies sequential block commitment. +func TestSingleValidatorMultipleBlocks(t *testing.T) { + id := newID(t) + genesis := genesisFor(id) + + committed := newCommitted() + net := &network{} + + engine := consensus.NewEngine( + id, + []string{id.PubKeyHex()}, + 1, + committed.onCommit, + net.broadcast, + ) + net.addEngine(engine) + + prev := genesis + for i := uint64(1); i <= 3; i++ { + engine.Propose(prev) + b := committed.waitOne(t, 3*time.Second) + if b.Index != i { + t.Errorf("block %d: expected index %d, got %d", i, i, b.Index) + } + engine.SyncSeqNum(i + 1) + prev = b + } +} + +// TestThreeValidatorCommit verifies a 3-node network reaches consensus. +// Messages are delivered synchronously through the in-process network bus. +// With f=1, quorum = ⌈2*3/3⌉ = 2. +func TestThreeValidatorCommit(t *testing.T) { + ids := []*identity.Identity{newID(t), newID(t), newID(t)} + valSet := []string{ids[0].PubKeyHex(), ids[1].PubKeyHex(), ids[2].PubKeyHex()} + + genesis := genesisFor(ids[0]) // block 0 signed by ids[0] + + committed := [3]*committedBlocks{newCommitted(), newCommitted(), newCommitted()} + net := &network{} + + for i, id := range ids { + idx := i + engine := consensus.NewEngine( + id, valSet, 1, + func(b *blockchain.Block) { committed[idx].onCommit(b) }, + net.broadcast, + ) + net.addEngine(engine) + } + + // Leader for seqNum=1, view=0 is valSet[(1+0)%3] = ids[1]. + // Find and trigger the leader. + for i, e := range net.engines { + _ = i + e.Propose(genesis) + } + + // All three should commit the same block. + timeout := 5 * time.Second + var commitIdx [3]uint64 + for i := 0; i < 3; i++ { + b := committed[i].waitOne(t, timeout) + commitIdx[i] = b.Index + } + for i, idx := range commitIdx { + if idx != 1 { + t.Errorf("engine %d committed block at wrong index: got %d, want 1", i, idx) + } + } +} + +// TestAddTransactionAndPropose verifies that pending transactions appear in committed blocks. +func TestAddTransactionAndPropose(t *testing.T) { + id := newID(t) + sender := newID(t) + genesis := genesisFor(id) + + committed := newCommitted() + net := &network{} + + engine := consensus.NewEngine( + id, + []string{id.PubKeyHex()}, + 1, + committed.onCommit, + net.broadcast, + ) + net.addEngine(engine) + + // Build a valid signed transaction. + payload, _ := json.Marshal(blockchain.TransferPayload{}) + tx := &blockchain.Transaction{ + ID: "test-tx-1", + Type: blockchain.EventTransfer, + From: sender.PubKeyHex(), + To: id.PubKeyHex(), + Amount: 1000, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + // Sign with canonical bytes (matching validateTx). + signData, _ := json.Marshal(struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + From string `json:"from"` + To string `json:"to"` + Amount uint64 `json:"amount"` + Fee uint64 `json:"fee"` + Payload []byte `json:"payload"` + Timestamp time.Time `json:"timestamp"` + }{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp}) + tx.Signature = sender.Sign(signData) + + if err := engine.AddTransaction(tx); err != nil { + t.Fatalf("AddTransaction: %v", err) + } + + engine.Propose(genesis) + + b := committed.waitOne(t, 3*time.Second) + if len(b.Transactions) != 1 { + t.Errorf("expected 1 transaction in committed block, got %d", len(b.Transactions)) + } + if b.Transactions[0].ID != "test-tx-1" { + t.Errorf("wrong transaction in committed block: %s", b.Transactions[0].ID) + } +} + +// TestDuplicateTransactionRejected verifies the mempool deduplicates by TX ID. +func TestDuplicateTransactionRejected(t *testing.T) { + id := newID(t) + sender := newID(t) + net := &network{} + + engine := consensus.NewEngine( + id, + []string{id.PubKeyHex()}, + 1, + func(*blockchain.Block) {}, + net.broadcast, + ) + + payload, _ := json.Marshal(blockchain.TransferPayload{}) + tx := &blockchain.Transaction{ + ID: "dup-tx", + Type: blockchain.EventTransfer, + From: sender.PubKeyHex(), + To: id.PubKeyHex(), + Amount: 1000, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + } + signData, _ := json.Marshal(struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + From string `json:"from"` + To string `json:"to"` + Amount uint64 `json:"amount"` + Fee uint64 `json:"fee"` + Payload []byte `json:"payload"` + Timestamp time.Time `json:"timestamp"` + }{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp}) + tx.Signature = sender.Sign(signData) + + if err := engine.AddTransaction(tx); err != nil { + t.Fatalf("first AddTransaction: %v", err) + } + if err := engine.AddTransaction(tx); err == nil { + t.Fatal("expected duplicate transaction to be rejected, but it was accepted") + } +} + +// TestInvalidTxRejected verifies that transactions with bad signatures are rejected. +func TestInvalidTxRejected(t *testing.T) { + id := newID(t) + net := &network{} + + engine := consensus.NewEngine( + id, + []string{id.PubKeyHex()}, + 1, + func(*blockchain.Block) {}, + net.broadcast, + ) + + payload, _ := json.Marshal(blockchain.TransferPayload{}) + tx := &blockchain.Transaction{ + ID: "bad-sig-tx", + Type: blockchain.EventTransfer, + From: id.PubKeyHex(), + To: id.PubKeyHex(), + Amount: 1000, + Fee: blockchain.MinFee, + Payload: payload, + Timestamp: time.Now().UTC(), + Signature: []byte("not-a-real-signature"), + } + if err := engine.AddTransaction(tx); err == nil { + t.Fatal("expected transaction with bad signature to be rejected") + } +} + +// TestFeeBelowMinimumRejected verifies that sub-minimum fees are rejected by the engine. +func TestFeeBelowMinimumRejected(t *testing.T) { + id := newID(t) + net := &network{} + + engine := consensus.NewEngine( + id, + []string{id.PubKeyHex()}, + 1, + func(*blockchain.Block) {}, + net.broadcast, + ) + + payload, _ := json.Marshal(blockchain.TransferPayload{}) + tx := &blockchain.Transaction{ + ID: "low-fee-tx", + Type: blockchain.EventTransfer, + From: id.PubKeyHex(), + To: id.PubKeyHex(), + Amount: 1000, + Fee: blockchain.MinFee - 1, // one µT below minimum + Payload: payload, + Timestamp: time.Now().UTC(), + } + signData, _ := json.Marshal(struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + From string `json:"from"` + To string `json:"to"` + Amount uint64 `json:"amount"` + Fee uint64 `json:"fee"` + Payload []byte `json:"payload"` + Timestamp time.Time `json:"timestamp"` + }{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp}) + tx.Signature = id.Sign(signData) + + if err := engine.AddTransaction(tx); err == nil { + t.Fatal("expected transaction with fee below MinFee to be rejected") + } +} + +// TestUpdateValidators verifies that UpdateValidators takes effect on the next round. +// We start with a 1-validator network (quorum=1), commit one block, then shrink +// the set back to the same single validator — confirming the hot-reload path runs +// without panicking and that the engine continues to commit blocks normally. +func TestUpdateValidators(t *testing.T) { + id := newID(t) + genesis := genesisFor(id) + + committed := newCommitted() + net := &network{} + + engine := consensus.NewEngine( + id, + []string{id.PubKeyHex()}, + 1, + committed.onCommit, + net.broadcast, + ) + net.engines = []*consensus.Engine{engine} + + // Block 1. + engine.Propose(genesis) + b1 := committed.waitOne(t, 3*time.Second) + engine.SyncSeqNum(2) + + // Hot-reload: same single validator — ensures the method is exercised. + engine.UpdateValidators([]string{id.PubKeyHex()}) + + // Block 2 should still commit with the reloaded set. + engine.Propose(b1) + b2 := committed.waitOne(t, 3*time.Second) + if b2.Index != 2 { + t.Errorf("expected block index 2 after validator update, got %d", b2.Index) + } +} + +// TestSyncSeqNum verifies that SyncSeqNum advances the engine's expected block index. +func TestSyncSeqNum(t *testing.T) { + id := newID(t) + net := &network{} + + committed := newCommitted() + engine := consensus.NewEngine( + id, + []string{id.PubKeyHex()}, + 1, + committed.onCommit, + net.broadcast, + ) + net.engines = []*consensus.Engine{engine} + + // Simulate receiving a chain sync that jumps to block 5. + engine.SyncSeqNum(5) + + genesis := genesisFor(id) + // Build a fake block at index 5 to propose from. + b5 := &blockchain.Block{ + Index: 4, + Timestamp: time.Now().UTC(), + Transactions: []*blockchain.Transaction{}, + PrevHash: genesis.Hash, + Validator: id.PubKeyHex(), + TotalFees: 0, + } + b5.ComputeHash() + b5.Sign(id.PrivKey) + + engine.Propose(b5) + b := committed.waitOne(t, 3*time.Second) + if b.Index != 5 { + t.Errorf("expected committed block index 5, got %d", b.Index) + } +} diff --git a/contracts/auction/auction.wasm b/contracts/auction/auction.wasm new file mode 100644 index 0000000000000000000000000000000000000000..abb193144147b80f908c034610d5e1af1949518b GIT binary patch literal 2150 zcmb_czmFSL5T5tm?z8>Iah3oL)!RP+3WPwq0^N&*5TbwzN_)?@cW1NbJ$culpy<{O z6jTRvQQD+P8>P2I8zK5s5JgIZDDH$Ph+yWe*FVAmLgG&HzL}kGX1;IcEhNRU008k` zcc7}O64gLd;&c$unGOGhfvhSRgzP&dCs`E2ztsTfM8H!R0Qd|be-K@jPCmuMwCpF- z!+udt0k%A|H{Srk9>X!iB+Jq%wAj&MM^Pqa3ewxf%2sTvEI)*RPwjG=Oo{^nhHUz3 zdr6i|hAC+FyzF!56N(#_{c@Tn#cX<`E)(&)y(}Mo(mzT^hesu}8PYnLm3?lm<1G$i zODod>RABfoR03q{b*+!n<9vEUf*w+8>Niq*qkVWmQ^#eN!ZkhQUNU$w7)=gxcnQzs zHE?r-of@#eW5Io8FeDfs&*)8!_2w;uSZBym@3?EQH=880@+hBft!D;X<%sC8 z@daY1%ukxgGek@$`_{|I?{6bPX>>DR=?-GRU4XlX*jf?;cb}51XqhM2d0WWJi8yc& zE4iz7t+MiiE5d?R<5vN!sGsi!yA%<0C0gagI96Riv;n>*M0FXo!R=6rH2`DMSyOHi zUEzZmTWQtuOogHl-{J`OBM{Jit=$ekziA+J5aZhSZH?yq!UbdeQp%uGrw-_U0TIMt z;hkXjDmp>7n+tLYD{7aFn6aYY&{3TG4Wg^D;*OM?YbPV+;C*8Jz&PZ9s7HAglC#KI zMjad8jI~FeZy*=a%7}!dgcqA`rdzvzUb}4&86Wy&!>5CQj-3yfDu9$JE1ZA()`=x=s;c+#*R@A3ulk z>^zfIoVKCWAJGil(^bbC%F?7153e0dQk&+wpk=gSq+hZ6Lq>7spIDow`H)AVVnuWF z17m=jhqT0+Q0RxZh2XTyVQjQUH4J?VoZp71hrbIFQC_x8+<~7&)4~5CpCh7_cH|tAXGjg07PI zNA}O%mH)Ur8|zK10vmW%4~^?>k>9*q^Cd-IjQSeVWww$F-`&LVTOu8Gd0n>NkN}Uj z1z3|{0dB4CI({#t`WFEt-yW*fjGxPGeJxmKPoRlh&FI3ocHN|>TYoG567|fuRufg< l%X5D(>+@rOzKxXW4!R)fh4lF+<373$?eEgMhpt5y{s}npuP*=q literal 0 HcmV?d00001 diff --git a/contracts/auction/auction_abi.json b/contracts/auction/auction_abi.json new file mode 100644 index 0000000..7022b7a --- /dev/null +++ b/contracts/auction/auction_abi.json @@ -0,0 +1,45 @@ +{ + "contract": "auction", + "version": "1.0.0", + "description": "English auction with on-chain token escrow. Bids are locked in the contract treasury. When an auction settles, the seller receives the top bid; outbid funds are automatically refunded.", + "methods": [ + { + "name": "create", + "description": "Create a new auction. Logs 'created: '. auction_id = block_height:seq (seq auto-incremented).", + "args": [ + {"name": "title", "type": "string", "description": "Human-readable item title (max 128 chars)"}, + {"name": "min_bid", "type": "uint64", "description": "Minimum opening bid in µT"}, + {"name": "duration", "type": "uint64", "description": "Duration in blocks"} + ] + }, + { + "name": "bid", + "description": "Place a bid on an open auction. Caller transfers bid amount to the contract treasury; previous top bidder is refunded. Logs 'bid: '.", + "args": [ + {"name": "auction_id", "type": "string", "description": "Auction ID returned by create"}, + {"name": "amount", "type": "uint64", "description": "Bid amount in µT (must exceed current top bid)"} + ] + }, + { + "name": "settle", + "description": "Settle an ended auction. Transfers winning bid to seller. Anyone may call after the end block. Logs 'settled: '.", + "args": [ + {"name": "auction_id", "type": "string", "description": "Auction ID to settle"} + ] + }, + { + "name": "cancel", + "description": "Cancel an auction before any bids. Only the seller may cancel. Logs 'cancelled: '.", + "args": [ + {"name": "auction_id", "type": "string", "description": "Auction ID to cancel"} + ] + }, + { + "name": "info", + "description": "Query auction info. Logs 'seller: ...', 'title: ...', 'top_bid: ...', 'end_block: ...', 'status: open/settled/cancelled'.", + "args": [ + {"name": "auction_id", "type": "string", "description": "Auction ID"} + ] + } + ] +} diff --git a/contracts/auction/gen/main.go b/contracts/auction/gen/main.go new file mode 100644 index 0000000..b937286 --- /dev/null +++ b/contracts/auction/gen/main.go @@ -0,0 +1,784 @@ +// gen generates contracts/auction/auction.wasm +// Run from repo root: go run ./contracts/auction/gen/ +// +// Methods: create, bid, settle, cancel, info +// +// State keys (per auction_id, single-char suffix): +// "a::s" → seller address +// "a::t" → title +// "a::m" → min_bid (8-byte big-endian u64 via put_u64/get_u64) +// "a::e" → end_block (8-byte big-endian u64) +// "a::b" → top_bidder address (empty string = no bid) +// "a::v" → top_bid amount (8-byte big-endian u64) +// "a::x" → status byte ('o'=open, 's'=settled, 'c'=cancelled) +// +// The contract treasury holds in-flight bid escrow. +package main + +import ( + "fmt" + "os" +) + +// ── LEB128 & builders ──────────────────────────────────────────────────────── + +func u(v uint64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + if v != 0 { + bt |= 0x80 + } + b = append(b, bt) + if v == 0 { + return b + } + } +} + +func s(v int64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + sign := (bt & 0x40) != 0 + if (v == 0 && !sign) || (v == -1 && sign) { + return append(b, bt) + } + b = append(b, bt|0x80) + } +} + +func cat(slices ...[]byte) []byte { + var out []byte + for _, sl := range slices { + out = append(out, sl...) + } + return out +} + +func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) } + +func section(id byte, content []byte) []byte { + return cat([]byte{id}, u(uint64(len(content))), content) +} + +func vec(items ...[]byte) []byte { + out := u(uint64(len(items))) + for _, it := range items { + out = append(out, it...) + } + return out +} + +func functype(params, results []byte) []byte { + return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results) +} + +func importFunc(mod, name string, typeIdx uint32) []byte { + return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx))) +} + +func exportEntry(name string, kind byte, idx uint32) []byte { + return cat(wstr(name), []byte{kind}, u(uint64(idx))) +} + +func dataSegment(offset int32, data []byte) []byte { + return cat( + []byte{0x00}, + []byte{0x41}, s(int64(offset)), []byte{0x0B}, + u(uint64(len(data))), data, + ) +} + +func funcBody(localDecls []byte, instrs ...[]byte) []byte { + inner := cat(localDecls) + for _, ins := range instrs { + inner = append(inner, ins...) + } + inner = append(inner, 0x0B) + return cat(u(uint64(len(inner))), inner) +} + +var noLocals = u(0) + +func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) } +func withLocals(decls ...[]byte) []byte { + return cat(u(uint64(len(decls))), cat(decls...)) +} + +const ( + tI32 byte = 0x7F + tI64 byte = 0x7E +) + +func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) } +func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) } +func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) } +func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) } +func ic64(v int64) []byte { return cat([]byte{0x42}, s(v)) } +func block_() []byte { return []byte{0x02, 0x40} } +func loop_() []byte { return []byte{0x03, 0x40} } +func if_() []byte { return []byte{0x04, 0x40} } +func else_() []byte { return []byte{0x05} } +func end_() []byte { return []byte{0x0B} } +func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) } +func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) } +func return_() []byte { return []byte{0x0F} } +func drop() []byte { return []byte{0x1A} } +func i32Eqz() []byte { return []byte{0x45} } +func i32Ne() []byte { return []byte{0x47} } +func i32GtU() []byte { return []byte{0x4B} } +func i32GeU() []byte { return []byte{0x4F} } +func i32Add() []byte { return []byte{0x6A} } +func i64Eqz() []byte { return []byte{0x50} } +func i64Eq() []byte { return []byte{0x51} } +func i64GtU() []byte { return []byte{0x56} } +func i64LeU() []byte { return []byte{0x57} } +func i64Add() []byte { return []byte{0x7C} } +func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} } +func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} } + +// ── Memory layout ───────────────────────────────────────────────────────────── +// +// 0x000 64 arg[0]: auction_id +// 0x040 128 arg[1]: title +// 0x140 128 caller buffer +// 0x1C0 64 treasury buffer +// 0x200 128 state-read: seller or top_bidder +// 0x280 128 secondary state-read: top_bidder (during bid) +// 0x300 2 state-read: status byte +// 0x310 256 scratch (buildField writes here) +// +// Constant strings at 0x500+: +// 0x500 10 "created: " +// 0x50A 5 "bid: " +// 0x510 10 "settled: " +// 0x51A 11 "cancelled: " +// 0x526 14 "unauthorized: " +// 0x535 9 "not found: " +// 0x540 12 "not open: " (auction not in open state) +// 0x54C 14 "bidding open: " (cannot cancel with bids) +// 0x55B 12 "still open: " (auction has not ended yet) +// 0x568 11 "low bid: " (bid too low) +// 0x574 8 "seller: " +// 0x57D 7 "title: " +// 0x585 10 "top bid: " +// 0x590 11 "end block: " +// 0x59C 8 "status: " +// 0x5A5 5 "open" +// 0x5AA 8 "settled" +// 0x5B3 10 "cancelled" + +const ( + offArg0 int32 = 0x000 + offArg1 int32 = 0x040 + offCaller int32 = 0x140 + offTreasury int32 = 0x1C0 + offRead1 int32 = 0x200 // seller / first state read + offRead2 int32 = 0x280 // top_bidder / second state read + offReadStat int32 = 0x300 // status byte + offScratch int32 = 0x310 // key scratch + + offCreatedPfx int32 = 0x500 + offBidPfx int32 = 0x50A + offSettledPfx int32 = 0x510 + offCancelledPfx int32 = 0x51A + offUnauthPfx int32 = 0x526 + offNotFoundPfx int32 = 0x535 + offNotOpenPfx int32 = 0x540 + offHasBidsPfx int32 = 0x54C + offStillOpenPfx int32 = 0x55B + offLowBidPfx int32 = 0x568 + offSellerPfx int32 = 0x574 + offTitlePfx int32 = 0x57D + offTopBidPfx int32 = 0x585 + offEndBlockPfx int32 = 0x590 + offStatusPfx int32 = 0x59C + offStrOpen int32 = 0x5A5 + offStrSettled int32 = 0x5AA + offStrCancelled int32 = 0x5B3 +) + +// ── Import / function indices ───────────────────────────────────────────────── + +const ( + fnGetArgStr = 0 + fnGetArgU64 = 1 + fnGetCaller = 2 + fnGetState = 3 + fnSetState = 4 + fnLog = 5 + fnTransfer = 6 + fnGetBalance = 7 + fnGetContractTreasury = 8 + fnGetBlockHeight = 9 + fnPutU64 = 10 + fnGetU64 = 11 + + fnBytesEqual = 12 + fnMemcpy = 13 + fnLogPrefix = 14 + fnBuildField = 15 // buildField(idOff, idLen, fieldChar i32) → keyLen i32 + fnCreate = 16 + fnBid = 17 + fnSettle = 18 + fnCancel = 19 + fnInfo = 20 +) + +// ── Helper bodies ───────────────────────────────────────────────────────────── + +func bytesEqualBody() []byte { + return funcBody( + withLocals(localDecl(2, tI32)), + ic32(1), lset(4), ic32(0), lset(3), + block_(), loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), + lget(0), lget(3), i32Add(), i32Load8U(), + lget(1), lget(3), i32Add(), i32Load8U(), + i32Ne(), if_(), ic32(0), lset(4), br_(2), end_(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), end_(), end_(), + lget(4), + ) +} + +func memcpyBody() []byte { + return funcBody( + withLocals(localDecl(1, tI32)), + ic32(0), lset(3), + block_(), loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), + lget(0), lget(3), i32Add(), + lget(1), lget(3), i32Add(), i32Load8U(), + i32Store8(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), end_(), end_(), + ) +} + +func logPrefixBody() []byte { + return funcBody( + noLocals, + ic32(offScratch), lget(0), lget(1), call(fnMemcpy), + ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy), + ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog), + ) +} + +// buildField(idOff, idLen, fieldChar i32) → keyLen i32 +// Writes "a::" into offScratch. Returns idLen+4. +func buildFieldBody() []byte { + return funcBody( + noLocals, + // scratch[0] = 'a' + ic32(offScratch), ic32('a'), i32Store8(), + // scratch[1] = ':' + ic32(offScratch+1), ic32(':'), i32Store8(), + // memcpy(scratch+2, idOff, idLen) + ic32(offScratch+2), lget(0), lget(1), call(fnMemcpy), + // scratch[2+idLen] = ':' + ic32(offScratch+2), lget(1), i32Add(), ic32(':'), i32Store8(), + // scratch[3+idLen] = fieldChar + ic32(offScratch+3), lget(1), i32Add(), lget(2), i32Store8(), + // return idLen + 4 + lget(1), ic32(4), i32Add(), + ) +} + +// ── Contract methods ────────────────────────────────────────────────────────── + +// create(id string, title string, min_bid u64, duration u64) +// Locals: idLen(0), titleLen(1), treasuryLen(2), keyLen(3) +// i64 locals: minBid(4), duration(5) +func createBody() []byte { + return funcBody( + withLocals(localDecl(4, tI32), localDecl(2, tI64)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1), + lget(1), i32Eqz(), if_(), return_(), end_(), + + // minBid = get_arg_u64(2) + ic32(2), call(fnGetArgU64), lset(4), + // duration = get_arg_u64(3) + ic32(3), call(fnGetArgU64), lset(5), + + // Check not already registered: get_state("a::x", ...) + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offReadStat), ic32(2), call(fnGetState), + ic32(0), i32GtU(), if_(), // status exists → already created + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Get treasury address + ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(2), + + // Get caller (seller) + ic32(offCaller), ic32(128), call(fnGetCaller), lset(2), // reuse local 2 for callerLen + + // Write seller: a::s → caller + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offCaller), lget(2), call(fnSetState), + + // Write title: a::t → title + ic32(offArg0), lget(0), ic32('t'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offArg1), lget(1), call(fnSetState), + + // Write min_bid: a::m → put_u64 + ic32(offArg0), lget(0), ic32('m'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), lget(4), call(fnPutU64), + + // Write end_block: a::e → get_block_height() + duration + call(fnGetBlockHeight), lget(5), i64Add(), + ic32(offArg0), lget(0), ic32('e'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), + // stack: keyPtr, keyLen, [endBlock i64 still on stack? No — need to rearrange] + // Actually put_u64 takes (keyPtr, keyLen, val i64) + // We already have endBlock on stack but called buildField after. Let me restructure. + drop(), // drop keyLen from buildField - oops this won't work as coded above + // Actually above I wrote lset(3) to save keyLen, so stack should be clean. + // The i64 (endBlock) is on the operand stack before the buildField call... this is wrong. + // Let me restructure: compute endBlock first into an i64 local. + // I need another i64 local. Let me add local 6 (i64). + // This requires restructuring... see revised body below + ) +} + +// Revised create body with proper local management +// Locals: idLen(0), titleLen(1), callerLen(2), keyLen(3) [i32] +// minBid(4), duration(5), endBlock(6) [i64] +func createBodyV2() []byte { + return funcBody( + withLocals(localDecl(4, tI32), localDecl(3, tI64)), + + // Read args + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1), + lget(1), i32Eqz(), if_(), return_(), end_(), + ic32(2), call(fnGetArgU64), lset(4), // minBid + ic32(3), call(fnGetArgU64), lset(5), // duration + + // Check id not already used: read status key + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offReadStat), ic32(2), call(fnGetState), + ic32(0), i32GtU(), if_(), + // ID already exists — log conflict + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Get caller (seller) + ic32(offCaller), ic32(128), call(fnGetCaller), lset(2), + + // Compute endBlock = get_block_height() + duration + call(fnGetBlockHeight), lget(5), i64Add(), lset(6), + + // Write seller: a::s → caller + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offCaller), lget(2), call(fnSetState), + + // Write title: a::t → arg1 + ic32(offArg0), lget(0), ic32('t'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offArg1), lget(1), call(fnSetState), + + // Write min_bid: a::m + ic32(offArg0), lget(0), ic32('m'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), lget(4), call(fnPutU64), + + // Write end_block: a::e + ic32(offArg0), lget(0), ic32('e'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), lget(6), call(fnPutU64), + + // Write top_bid = 0: a::v + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic64(0), call(fnPutU64), + + // Write status = 'o': a::x + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3), + ic32(offReadStat), ic32('o'), i32Store8(), // write 'o' to offReadStat + ic32(offScratch), lget(3), ic32(offReadStat), ic32(1), call(fnSetState), + + ic32(offCreatedPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// bid(id string, amount u64) +// Locals: idLen(0), callerLen(1), sellerLen(2), topBidderLen(3), keyLen(4) [i32] +// amount(5), topBid(6), endBlock(7) [i64] +func bidBody() []byte { + return funcBody( + withLocals(localDecl(5, tI32), localDecl(3, tI64)), + + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + ic32(1), call(fnGetArgU64), lset(5), // amount + + // Check status == 'o' + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offReadStat), ic32(2), call(fnGetState), + ic32(0), i32Ne(), if_(), // status byte exists + ic32(offReadStat), i32Load8U(), ic32('o'), i32Ne(), if_(), + ic32(offNotOpenPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + else_(), + // no status byte = not found + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Check end_block > current block + ic32(offArg0), lget(0), ic32('e'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), call(fnGetU64), lset(7), + call(fnGetBlockHeight), lget(7), i64LeU(), if_(), // blockHeight >= endBlock → ended + ic32(offNotOpenPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Get current top_bid + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), call(fnGetU64), lset(6), + + // amount must be > max(topBid, minBid-1) → amount > topBid AND amount >= minBid + // Check amount > topBid + lget(5), lget(6), i64GtU(), i32Eqz(), if_(), + ic32(offLowBidPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Get caller + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + + // Get treasury + ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(2), // reuse local 2 + + // Transfer amount from caller to treasury + ic32(offCaller), lget(1), ic32(offTreasury), lget(2), lget(5), call(fnTransfer), drop(), + + // Refund previous top bidder if topBid > 0 + lget(6), i64Eqz(), i32Eqz(), if_(), // topBid > 0 + // Read top_bidder address into offRead2 + ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offRead2), ic32(128), call(fnGetState), lset(3), + lget(3), ic32(0), i32GtU(), if_(), // topBidder address exists + // transfer(treasury, topBidder, topBid) + ic32(offTreasury), lget(2), ic32(offRead2), lget(3), lget(6), call(fnTransfer), drop(), + end_(), + end_(), + + // Update top_bidder: a::b → caller + ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offCaller), lget(1), call(fnSetState), + + // Update top_bid: a::v → amount + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), lget(5), call(fnPutU64), + + ic32(offBidPfx), ic32(5), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// settle(id string) +// Locals: idLen(0), sellerLen(1), topBidderLen(2), treasuryLen(3), keyLen(4) [i32] +// topBid(5), endBlock(6) [i64] +func settleBody() []byte { + return funcBody( + withLocals(localDecl(5, tI32), localDecl(2, tI64)), + + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Check status == 'o' + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offReadStat), ic32(2), call(fnGetState), + ic32(0), i32Ne(), if_(), + ic32(offReadStat), i32Load8U(), ic32('o'), i32Ne(), if_(), + ic32(offNotOpenPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + else_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Check auction has ended: current_block >= end_block + ic32(offArg0), lget(0), ic32('e'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), call(fnGetU64), lset(6), + call(fnGetBlockHeight), lget(6), i64LeU(), if_(), // height < endBlock → still open + ic32(offStillOpenPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Get top_bid + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), call(fnGetU64), lset(5), + + // If top_bid > 0: transfer topBid from treasury to seller + lget(5), i64Eqz(), i32Eqz(), if_(), + // Read seller into offRead1 + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offRead1), ic32(128), call(fnGetState), lset(1), + // Get treasury + ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(3), + ic32(offTreasury), lget(3), ic32(offRead1), lget(1), lget(5), call(fnTransfer), drop(), + end_(), + + // Mark status = 's' + ic32(offReadStat), ic32('s'), i32Store8(), + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offReadStat), ic32(1), call(fnSetState), + + ic32(offSettledPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// cancel(id string) — seller cancels (no bids yet) +// Locals: idLen(0), callerLen(1), sellerLen(2), keyLen(3) [i32] +// topBid(4) [i64] +func cancelBody() []byte { + return funcBody( + withLocals(localDecl(4, tI32), localDecl(1, tI64)), + + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Check status == 'o' + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offReadStat), ic32(2), call(fnGetState), + ic32(0), i32Ne(), if_(), + ic32(offReadStat), i32Load8U(), ic32('o'), i32Ne(), if_(), + ic32(offNotOpenPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + else_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Check no bids: topBid == 0 + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), call(fnGetU64), lset(4), + lget(4), i64Eqz(), i32Eqz(), if_(), // topBid > 0 → has bids + ic32(offHasBidsPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Verify caller is seller + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offRead1), ic32(128), call(fnGetState), lset(2), + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + // isOwner: callerLen == sellerLen && bytes_equal + lget(1), lget(2), i32Ne(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + ic32(offCaller), ic32(offRead1), lget(1), call(fnBytesEqual), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Mark status = 'c' + ic32(offReadStat), ic32('c'), i32Store8(), + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offReadStat), ic32(1), call(fnSetState), + + ic32(offCancelledPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// info(id string) — log auction details +// Locals: idLen(0), strLen(1), keyLen(2) [i32] +// topBid(3) [i64] +func infoBody() []byte { + return funcBody( + withLocals(localDecl(3, tI32), localDecl(1, tI64)), + + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Check exists + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(2), + ic32(offScratch), lget(2), ic32(offReadStat), ic32(2), call(fnGetState), + ic32(0), i32Ne(), i32Eqz(), if_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Log seller: a::s + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(2), + ic32(offScratch), lget(2), ic32(offRead1), ic32(128), call(fnGetState), lset(1), + ic32(offSellerPfx), ic32(8), ic32(offRead1), lget(1), call(fnLogPrefix), + + // Log title: a::t + ic32(offArg0), lget(0), ic32('t'), call(fnBuildField), lset(2), + ic32(offScratch), lget(2), ic32(offRead1), ic32(128), call(fnGetState), lset(1), + ic32(offTitlePfx), ic32(7), ic32(offRead1), lget(1), call(fnLogPrefix), + + // Log status + ic32(offReadStat), i32Load8U(), ic32('s'), i32Ne(), if_(), + ic32(offReadStat), i32Load8U(), ic32('c'), i32Ne(), if_(), + // status == 'o' + ic32(offStatusPfx), ic32(8), ic32(offStrOpen), ic32(4), call(fnLogPrefix), + else_(), + ic32(offStatusPfx), ic32(8), ic32(offStrCancelled), ic32(9), call(fnLogPrefix), + end_(), + else_(), + ic32(offStatusPfx), ic32(8), ic32(offStrSettled), ic32(7), call(fnLogPrefix), + end_(), + ) +} + +// ── main ────────────────────────────────────────────────────────────────────── + +func main() { + // Types: + // 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal + // 1: (i32)→(i64) get_arg_u64, get_block_height (no, diff sig) + // 2: (i32,i32,i32,i32)→(i32) get_state + // 3: (i32,i32,i32,i32)→() set_state, log_prefix + // 4: (i32,i32)→() log + // 5: (i32,i32,i32,i32,i64)→(i32) transfer + // 6: (i32,i32)→(i64) get_balance, get_u64 + // 7: (i32,i32)→(i32) get_caller, get_contract_treasury + // 8: ()→(i64) get_block_height + // 9: (i32,i32,i64)→() put_u64 + // 10: ()→() exported methods + // 11: (i32,i32,i32)→() memcpy + // 12: (i32,i32,i32)→(i32) buildField + typeSection := section(0x01, vec( + functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0 + functype([]byte{tI32}, []byte{tI64}), // 1 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3 + functype([]byte{tI32, tI32}, []byte{}), // 4 + functype([]byte{tI32, tI32, tI32, tI32, tI64}, []byte{tI32}), // 5 + functype([]byte{tI32, tI32}, []byte{tI64}), // 6 + functype([]byte{tI32, tI32}, []byte{tI32}), // 7 + functype([]byte{}, []byte{tI64}), // 8 + functype([]byte{tI32, tI32, tI64}, []byte{}), // 9 + functype([]byte{}, []byte{}), // 10 + functype([]byte{tI32, tI32, tI32}, []byte{}), // 11 + )) + + importSection := section(0x02, vec( + importFunc("env", "get_arg_str", 0), // 0 + importFunc("env", "get_arg_u64", 1), // 1 + importFunc("env", "get_caller", 7), // 2 + importFunc("env", "get_state", 2), // 3 + importFunc("env", "set_state", 3), // 4 + importFunc("env", "log", 4), // 5 + importFunc("env", "transfer", 5), // 6 + importFunc("env", "get_balance", 6), // 7 + importFunc("env", "get_contract_treasury", 7), // 8 + importFunc("env", "get_block_height", 8), // 9 + importFunc("env", "put_u64", 9), // 10 + importFunc("env", "get_u64", 6), // 11 + )) + + // 9 local functions + functionSection := section(0x03, vec( + u(0), // bytes_equal type 0 + u(11), // memcpy type 11 + u(3), // log_prefix type 3 + u(0), // buildField type 0 (i32,i32,i32)→(i32) + u(10), // create type 10 + u(10), // bid type 10 + u(10), // settle type 10 + u(10), // cancel type 10 + u(10), // info type 10 + )) + + memorySection := section(0x05, vec(cat([]byte{0x00}, u(2)))) // 2 pages + + exportSection := section(0x07, vec( + exportEntry("memory", 0x02, 0), + exportEntry("create", 0x00, fnCreate), + exportEntry("bid", 0x00, fnBid), + exportEntry("settle", 0x00, fnSettle), + exportEntry("cancel", 0x00, fnCancel), + exportEntry("info", 0x00, fnInfo), + )) + + dataSection := section(0x0B, cat( + u(17), + dataSegment(offCreatedPfx, []byte("created: ")), + dataSegment(offBidPfx, []byte("bid: ")), + dataSegment(offSettledPfx, []byte("settled: ")), + dataSegment(offCancelledPfx, []byte("cancelled: ")), + dataSegment(offUnauthPfx, []byte("unauthorized: ")), + dataSegment(offNotFoundPfx, []byte("not found: ")), + dataSegment(offNotOpenPfx, []byte("not open: ")), + dataSegment(offHasBidsPfx, []byte("has bids: ")), + dataSegment(offStillOpenPfx, []byte("still open: ")), + dataSegment(offLowBidPfx, []byte("low bid: ")), + dataSegment(offSellerPfx, []byte("seller: ")), + dataSegment(offTitlePfx, []byte("title: ")), + dataSegment(offTopBidPfx, []byte("top bid: ")), + dataSegment(offEndBlockPfx, []byte("end block: ")), + dataSegment(offStatusPfx, []byte("status: ")), + dataSegment(offStrOpen, []byte("open")), + dataSegment(offStrSettled, []byte("settled")), + // Note: offStrCancelled is at 0x5B3 but we declared 17 segments + // Add cancelled string too + )) + // Fix: 18 data segments including "cancelled" + dataSection = section(0x0B, cat( + u(18), + dataSegment(offCreatedPfx, []byte("created: ")), + dataSegment(offBidPfx, []byte("bid: ")), + dataSegment(offSettledPfx, []byte("settled: ")), + dataSegment(offCancelledPfx, []byte("cancelled: ")), + dataSegment(offUnauthPfx, []byte("unauthorized: ")), + dataSegment(offNotFoundPfx, []byte("not found: ")), + dataSegment(offNotOpenPfx, []byte("not open: ")), + dataSegment(offHasBidsPfx, []byte("has bids: ")), + dataSegment(offStillOpenPfx, []byte("still open: ")), + dataSegment(offLowBidPfx, []byte("low bid: ")), + dataSegment(offSellerPfx, []byte("seller: ")), + dataSegment(offTitlePfx, []byte("title: ")), + dataSegment(offTopBidPfx, []byte("top bid: ")), + dataSegment(offEndBlockPfx, []byte("end block: ")), + dataSegment(offStatusPfx, []byte("status: ")), + dataSegment(offStrOpen, []byte("open")), + dataSegment(offStrSettled, []byte("settled")), + dataSegment(offStrCancelled, []byte("cancelled")), + )) + + codeSection := section(0x0A, cat( + u(9), + bytesEqualBody(), + memcpyBody(), + logPrefixBody(), + buildFieldBody(), + createBodyV2(), + bidBody(), + settleBody(), + cancelBody(), + infoBody(), + )) + + module := cat( + []byte{0x00, 0x61, 0x73, 0x6d}, + []byte{0x01, 0x00, 0x00, 0x00}, + typeSection, + importSection, + functionSection, + memorySection, + exportSection, + dataSection, + codeSection, + ) + + out := "contracts/auction/auction.wasm" + if err := os.WriteFile(out, module, 0644); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } + fmt.Printf("Written %s (%d bytes)\n", out, len(module)) +} diff --git a/contracts/counter/counter.wasm b/contracts/counter/counter.wasm new file mode 100644 index 0000000000000000000000000000000000000000..110559cdcc26fd8d90522e06bf54682a5f5dcbab GIT binary patch literal 492 zcmY+8%}T>S6ot>7$zMAW^9U1gB?vCWb#Fmjx)XOMv_oxe5=j$9k;aGdLHY>AC+MR% zlSsi?%$#$W^WBSlQxE_;4>Q?yTV$f^XzT7lcA3}NLw_$aKs*G}l&|4S+dQ_{S3vI; zW^m-3TvrPSr^YUyjR55Q32tZK`~PT)lO)m!P5R_a(YbMU2dtLT0eYj;pl57E^#cn=k~ zW!^SVRlWQ$^YIVKlxWM~FrgG0Q~FHpC=N9e3ul!G6>= 7 + if v != 0 { + bt |= 0x80 + } + b = append(b, bt) + if v == 0 { + return b + } + } +} + +func s(v int64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + sign := (bt & 0x40) != 0 + if (v == 0 && !sign) || (v == -1 && sign) { + return append(b, bt) + } + b = append(b, bt|0x80) + } +} + +// ── Builders ────────────────────────────────────────────────────────────────── + +func cat(slices ...[]byte) []byte { + var out []byte + for _, s := range slices { + out = append(out, s...) + } + return out +} + +func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) } + +func section(id byte, content []byte) []byte { + return cat([]byte{id}, u(uint64(len(content))), content) +} + +// vec encodes a vector: count followed by concatenated items. +func vec(items ...[]byte) []byte { + out := u(uint64(len(items))) + for _, it := range items { + out = append(out, it...) + } + return out +} + +// functype encodes a WASM function type (0x60 prefix). +func functype(params, results []byte) []byte { + return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results) +} + +// importFunc encodes a function import entry. +func importFunc(mod, name string, typeIdx uint32) []byte { + return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx))) +} + +// exportEntry encodes an export entry. +func exportEntry(name string, kind byte, idx uint32) []byte { + return cat(wstr(name), []byte{kind}, u(uint64(idx))) +} + +// dataSegment encodes an active data segment for memory 0. +func dataSegment(offset int32, data []byte) []byte { + return cat( + []byte{0x00}, // active segment, implicit mem 0 + []byte{0x41}, s(int64(offset)), []byte{0x0B}, // i32.const offset; end + u(uint64(len(data))), data, + ) +} + +// funcBody encodes one function body: localDecls + instructions + end. +func funcBody(localDecls []byte, instrs ...[]byte) []byte { + inner := cat(localDecls) + for _, ins := range instrs { + inner = append(inner, ins...) + } + inner = append(inner, 0x0B) // end + return cat(u(uint64(len(inner))), inner) +} + +// noLocals is an empty local decl list. +var noLocals = u(0) + +// localDecl encodes n locals of a given type. +func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) } +func withLocals(decls ...[]byte) []byte { + return cat(u(uint64(len(decls))), cat(decls...)) +} + +// ── Instructions ────────────────────────────────────────────────────────────── + +const ( + tI32 byte = 0x7F + tI64 byte = 0x7E +) + +func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) } +func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) } +func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) } +func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) } +func ic64(v int64) []byte { return cat([]byte{0x42}, s(v)) } +func block_() []byte { return []byte{0x02, 0x40} } +func loop_() []byte { return []byte{0x03, 0x40} } +func if_() []byte { return []byte{0x04, 0x40} } +func end_() []byte { return []byte{0x0B} } +func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) } +func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) } +func return_() []byte { return []byte{0x0F} } +func i32Eqz() []byte { return []byte{0x45} } +func i32Ne() []byte { return []byte{0x47} } +func i32GeU() []byte { return []byte{0x4F} } +func i32Add() []byte { return []byte{0x6A} } +func i64Add() []byte { return []byte{0x7C} } +func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} } // align=0, offset=0 + +// ── Memory layout constants ─────────────────────────────────────────────────── + +const ( + offCounter = 0x00 // "counter" (7 bytes) + offOwner = 0x10 // "owner" (5 bytes) + offIncMsg = 0x20 // "incremented" (11 bytes) + offGetMsg = 0x30 // "get called" (10 bytes) + offResetOk = 0x40 // "reset ok" (8 bytes) + offUnauth = 0x50 // "unauthorized" (12 bytes) + offCallerBuf = 0x60 // caller buf (128 bytes) + offOwnerBuf = 0xE0 // owner buf (128 bytes) +) + +// Import function indices +const ( + fnPutU64 = 0 // put_u64(keyPtr, keyLen i32, val i64) + fnGetU64 = 1 // get_u64(keyPtr, keyLen i32) → i64 + fnLog = 2 // log(msgPtr, msgLen i32) + fnGetCaller = 3 // get_caller(bufPtr, bufLen i32) → i32 + fnGetState = 4 // get_state(kPtr,kLen,dPtr,dLen i32) → i32 + fnSetState = 5 // set_state(kPtr,kLen,vPtr,vLen i32) +) + +// Local function indices (imports are 0-5, locals start at 6) +const ( + fnIncrement = 6 + fnGet = 7 + fnReset = 8 +) + +func main() { + // ── Type section ───────────────────────────────────────────────────────── + // Type 0: (i32,i32,i64)→() put_u64 + // Type 1: (i32,i32)→(i64) get_u64 + // Type 2: (i32,i32)→() log + // Type 3: (i32,i32)→(i32) get_caller + // Type 4: (i32,i32,i32,i32)→(i32) get_state + // Type 5: (i32,i32,i32,i32)→() set_state + // Type 6: ()→() increment, get, reset + typeSection := section(0x01, vec( + functype([]byte{tI32, tI32, tI64}, []byte{}), // 0 + functype([]byte{tI32, tI32}, []byte{tI64}), // 1 + functype([]byte{tI32, tI32}, []byte{}), // 2 + functype([]byte{tI32, tI32}, []byte{tI32}), // 3 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 4 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 5 + functype([]byte{}, []byte{}), // 6 + )) + + // ── Import section ──────────────────────────────────────────────────────── + importSection := section(0x02, vec( + importFunc("env", "put_u64", fnPutU64), + importFunc("env", "get_u64", fnGetU64), + importFunc("env", "log", fnLog), + importFunc("env", "get_caller", fnGetCaller), + importFunc("env", "get_state", fnGetState), + importFunc("env", "set_state", fnSetState), + )) + + // ── Function section: 3 local functions, all type 6 ────────────────────── + functionSection := section(0x03, vec(u(6), u(6), u(6))) + + // ── Memory section: 1 page (64 KiB) ────────────────────────────────────── + // limits type 0x00 = min only; type 0x01 = min+max + memorySection := section(0x05, vec(cat([]byte{0x00}, u(1)))) // min=1, no max + + // ── Export section ──────────────────────────────────────────────────────── + exportSection := section(0x07, vec( + exportEntry("memory", 0x02, 0), + exportEntry("increment", 0x00, fnIncrement), + exportEntry("get", 0x00, fnGet), + exportEntry("reset", 0x00, fnReset), + )) + + // ── Data section ────────────────────────────────────────────────────────── + dataSection := section(0x0B, cat( + u(6), // 6 segments + dataSegment(offCounter, []byte("counter")), + dataSegment(offOwner, []byte("owner")), + dataSegment(offIncMsg, []byte("incremented")), + dataSegment(offGetMsg, []byte("get called")), + dataSegment(offResetOk, []byte("reset ok")), + dataSegment(offUnauth, []byte("unauthorized")), + )) + + // ── Code section ───────────────────────────────────────────────────────── + + // increment(): + // local $val i64 + // $val = get_u64("counter") + // $val++ + // put_u64("counter", $val) + // log("incremented") + incrementBody := funcBody( + withLocals(localDecl(1, tI64)), + ic32(offCounter), ic32(7), call(fnGetU64), lset(0), + lget(0), ic64(1), i64Add(), lset(0), + ic32(offCounter), ic32(7), lget(0), call(fnPutU64), + ic32(offIncMsg), ic32(11), call(fnLog), + ) + + // get(): + // log("get called") + getBody := funcBody( + noLocals, + ic32(offGetMsg), ic32(10), call(fnLog), + ) + + // reset(): + // locals: callerLen(0), ownerLen(1), i(2), same(3) — all i32 + // callerLen = get_caller(callerBuf, 128) + // ownerLen = get_state("owner", ownerBuf, 128) + // if ownerLen == 0: + // set_state("owner", callerBuf[:callerLen]) + // put_u64("counter", 0) + // log("reset ok") + // return + // if callerLen != ownerLen: log unauthorized; return + // same = 1; i = 0 + // block: + // loop: + // if i >= callerLen: br 1 (exit block) + // if callerBuf[i] != ownerBuf[i]: same=0; br 1 + // i++; continue loop + // if !same: log unauthorized; return + // put_u64("counter", 0); log("reset ok") + resetBody := funcBody( + withLocals(localDecl(4, tI32)), + // callerLen = get_caller(callerBuf, 128) + ic32(offCallerBuf), ic32(128), call(fnGetCaller), lset(0), + // ownerLen = get_state("owner", 5, ownerBuf, 128) + ic32(offOwner), ic32(5), ic32(offOwnerBuf), ic32(128), call(fnGetState), lset(1), + // if ownerLen == 0: + lget(1), i32Eqz(), if_(), + ic32(offOwner), ic32(5), ic32(offCallerBuf), lget(0), call(fnSetState), + ic32(offCounter), ic32(7), ic64(0), call(fnPutU64), + ic32(offResetOk), ic32(8), call(fnLog), + return_(), + end_(), + // if callerLen != ownerLen: unauthorized + lget(0), lget(1), i32Ne(), if_(), + ic32(offUnauth), ic32(12), call(fnLog), + return_(), + end_(), + // same = 1; i = 0 + ic32(1), lset(3), + ic32(0), lset(2), + // block $break + block_(), + loop_(), + lget(2), lget(0), i32GeU(), brIf_(1), // i >= callerLen → break + // load callerBuf[i] + ic32(offCallerBuf), lget(2), i32Add(), i32Load8U(), + // load ownerBuf[i] + ic32(offOwnerBuf), lget(2), i32Add(), i32Load8U(), + i32Ne(), if_(), + ic32(0), lset(3), + br_(2), // break out of block + end_(), + lget(2), ic32(1), i32Add(), lset(2), + br_(0), // continue loop + end_(), + end_(), + // if !same: unauthorized + lget(3), i32Eqz(), if_(), + ic32(offUnauth), ic32(12), call(fnLog), + return_(), + end_(), + // authorized + ic32(offCounter), ic32(7), ic64(0), call(fnPutU64), + ic32(offResetOk), ic32(8), call(fnLog), + ) + + codeSection := section(0x0A, cat(u(3), incrementBody, getBody, resetBody)) + + // ── Assemble module ─────────────────────────────────────────────────────── + module := cat( + []byte{0x00, 0x61, 0x73, 0x6d}, // magic \0asm + []byte{0x01, 0x00, 0x00, 0x00}, // version 1 + typeSection, + importSection, + functionSection, + memorySection, + exportSection, + dataSection, + codeSection, + ) + + out := "contracts/counter/counter.wasm" + if err := os.WriteFile(out, module, 0644); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } + fmt.Printf("Written %s (%d bytes)\n", out, len(module)) +} diff --git a/contracts/counter/main.go b/contracts/counter/main.go new file mode 100644 index 0000000..05e049c --- /dev/null +++ b/contracts/counter/main.go @@ -0,0 +1,137 @@ +// Counter smart contract — compiles to WASM with GOOS=wasip1 GOARCH=wasm. +// +// Methods (exported via //go:export): +// - increment — adds 1 to the stored counter +// - get — logs the current value (readable via /api/contracts/{id}/state/counter) +// - reset — resets counter to 0; only the first caller (owner) is allowed +// +// Host imports from the "env" module (see vm/host.go): +// - put_u64(keyPtr, keyLen, val) — stores uint64 as 8-byte big-endian +// - get_u64(keyPtr, keyLen) uint64 — reads 8-byte big-endian uint64 +// - get_caller(buf, bufLen) int32 — writes caller pub key hex into buf +// - get_state(kPtr,kLen,dPtr,dLen) int32 — reads raw state bytes +// - set_state(kPtr,kLen,vPtr,vLen) — writes raw state bytes +// - log(msgPtr, msgLen) — emits message to node log +// +//go:build wasip1 + +package main + +import ( + "unsafe" +) + +// ── host function imports ───────────────────────────────────────────────────── + +//go:wasmimport env put_u64 +func hostPutU64(keyPtr unsafe.Pointer, keyLen int32, val uint64) + +//go:wasmimport env get_u64 +func hostGetU64(keyPtr unsafe.Pointer, keyLen int32) uint64 + +//go:wasmimport env get_caller +func hostGetCaller(buf unsafe.Pointer, bufLen int32) int32 + +//go:wasmimport env get_state +func hostGetState(keyPtr unsafe.Pointer, keyLen int32, dstPtr unsafe.Pointer, dstLen int32) int32 + +//go:wasmimport env set_state +func hostSetState(keyPtr unsafe.Pointer, keyLen int32, valPtr unsafe.Pointer, valLen int32) + +//go:wasmimport env log +func hostLog(msgPtr unsafe.Pointer, msgLen int32) + +// ── helpers ─────────────────────────────────────────────────────────────────── + +func logMsg(s string) { + if len(s) == 0 { + return + } + b := []byte(s) + hostLog(unsafe.Pointer(&b[0]), int32(len(b))) +} + +func putU64(key string, val uint64) { + b := []byte(key) + hostPutU64(unsafe.Pointer(&b[0]), int32(len(b)), val) +} + +func getU64(key string) uint64 { + b := []byte(key) + return hostGetU64(unsafe.Pointer(&b[0]), int32(len(b))) +} + +func getState(key string, dst []byte) int32 { + kb := []byte(key) + return hostGetState(unsafe.Pointer(&kb[0]), int32(len(kb)), + unsafe.Pointer(&dst[0]), int32(len(dst))) +} + +func setState(key string, val []byte) { + kb := []byte(key) + hostSetState(unsafe.Pointer(&kb[0]), int32(len(kb)), + unsafe.Pointer(&val[0]), int32(len(val))) +} + +func getCaller() string { + buf := make([]byte, 128) + n := hostGetCaller(unsafe.Pointer(&buf[0]), int32(len(buf))) + if n <= 0 { + return "" + } + return string(buf[:n]) +} + +// ── contract state keys ─────────────────────────────────────────────────────── + +const ( + keyCounter = "counter" + keyOwner = "owner" +) + +// ── exported contract methods ───────────────────────────────────────────────── + +//go:export increment +func increment() { + val := getU64(keyCounter) + val++ + putU64(keyCounter, val) + logMsg("incremented") +} + +//go:export get +func get() { + logMsg("get called") +} + +//go:export reset +func reset() { + caller := getCaller() + if caller == "" { + logMsg("reset: no caller") + return + } + + ownerBuf := make([]byte, 128) + ownerLen := getState(keyOwner, ownerBuf) + + if ownerLen == 0 { + // No owner set yet — first caller becomes the owner. + setState(keyOwner, []byte(caller)) + putU64(keyCounter, 0) + logMsg("reset ok (owner set)") + return + } + + owner := string(ownerBuf[:ownerLen]) + if caller != owner { + logMsg("reset: unauthorized") + return + } + + putU64(keyCounter, 0) + logMsg("reset ok") +} + +// main is required by the Go runtime for wasip1 programs. +func main() {} diff --git a/contracts/escrow/escrow.wasm b/contracts/escrow/escrow.wasm new file mode 100644 index 0000000000000000000000000000000000000000..244f46424309d58a6809bcbb17f35be902b5632e GIT binary patch literal 2677 zcmcguy^mW(5TD(BANG##?(va^YW)X5g+RIj-4-Vh6#NP2$NAl1%V(qao=-Z8v(r!? zkRnx3DwK4QLX{#Xp)@I|5K>A|E(K*0AxdU;-}AF`P9%baZTaoY&irQQw=-iyS{w-g z5WCn@v)N3{dSWI{djYLka8KyT*$jFi>rP2Y5`B~TsoNoQ0Ot+zN1;_VKE}hW+)MMr zy`szkRy5NO-Uq=N%{Ke#Xq4p;v7*h2qD;#Sq?3z)#W9cSKbZgUsrQ3*+hXQ+tc4`o?_eiQU1pB~AK0mO(AchVe?5_mi!S!!j z_>qt^E4qP&u97=y$0#E|-w+0j8omo)gnxb;N8q6zRlA!h4bYrk!R zXdJK$_ADZ7ksvR`&`6`IH3dpS{E#i|1(2itHnQ9N{H}JPg&2C>_uiZFg$;)IDd%x6 z?OY_1qRh1o?VLU$QC&eM@EK)rq3tFa8Av)?=xv-7Q2p4AY6t%mGNcx@K<{%%pbG|e zgB|h^Br_y1q*uvAbVExP_HRfwh(}5*u=B{uMERhPv_&>>G3ygbd#)Hf?$SdV7K!;V ziQaKoe=K3~c0%_%uqWl!(bH zC9Z^dM5RG7c>P@4PMuz8*Cwmn;ayHO7*XVJMj62<5BX-6NeZYgG*P^|vml|yscyKT zbBPgeleD`Lvr7>}BTd8icJ^2q!(#IE{{f< zI=cZ~AGZea#4ASu$qD_qU24F3YWb1p29G}zwOLGKHe{|BU0NTvx~+h)`KQQ+p7~eU zEiU{;2x6Iprz-Xk$K= zNZdH|v%0ogbm4WhG@qH>4n=9Es9*bA^y{bYMsKqH1NurjA^i~`;hqz)lJ2ZXx6wvP G2k{@;mlxgu literal 0 HcmV?d00001 diff --git a/contracts/escrow/escrow_abi.json b/contracts/escrow/escrow_abi.json new file mode 100644 index 0000000..cfda23e --- /dev/null +++ b/contracts/escrow/escrow_abi.json @@ -0,0 +1,57 @@ +{ + "contract": "escrow", + "version": "1.0.0", + "description": "Two-party trustless escrow. Buyer deposits funds into the contract treasury. Seller delivers; buyer releases. If disputed, the contract admin resolves.", + "methods": [ + { + "name": "init", + "description": "Set the caller as the escrow admin. Call once after deployment.", + "args": [] + }, + { + "name": "create", + "description": "Buyer creates an escrow. Transfers amount from buyer to treasury. Logs 'created: '.", + "args": [ + {"name": "id", "type": "string", "description": "Unique escrow ID (user-supplied)"}, + {"name": "seller", "type": "string", "description": "Seller address (hex pubkey)"}, + {"name": "amount", "type": "uint64", "description": "Amount in µT to lock in escrow"} + ] + }, + { + "name": "release", + "description": "Buyer releases funds to seller. Transfers treasury → seller. Logs 'released: '.", + "args": [ + {"name": "id", "type": "string", "description": "Escrow ID"} + ] + }, + { + "name": "refund", + "description": "Seller refunds the buyer (voluntary). Transfers treasury → buyer. Logs 'refunded: '.", + "args": [ + {"name": "id", "type": "string", "description": "Escrow ID"} + ] + }, + { + "name": "dispute", + "description": "Buyer or seller raises a dispute. Logs 'disputed: '. Admin must then call resolve.", + "args": [ + {"name": "id", "type": "string", "description": "Escrow ID"} + ] + }, + { + "name": "resolve", + "description": "Admin resolves a disputed escrow. winner must be 'buyer' or 'seller'. Logs 'resolved: '.", + "args": [ + {"name": "id", "type": "string", "description": "Escrow ID"}, + {"name": "winner", "type": "string", "description": "'buyer' or 'seller'"} + ] + }, + { + "name": "info", + "description": "Log escrow details: buyer, seller, amount, status.", + "args": [ + {"name": "id", "type": "string", "description": "Escrow ID"} + ] + } + ] +} diff --git a/contracts/escrow/gen/main.go b/contracts/escrow/gen/main.go new file mode 100644 index 0000000..23c8c14 --- /dev/null +++ b/contracts/escrow/gen/main.go @@ -0,0 +1,818 @@ +// gen generates contracts/escrow/escrow.wasm +// Run from repo root: go run ./contracts/escrow/gen/ +// +// Methods: init, create, release, refund, dispute, resolve, info +// +// State keys (per escrow id): +// "admin" → admin address +// "e::b" → buyer address +// "e::s" → seller address +// "e::v" → amount (8-byte big-endian u64) +// "e::x" → status byte: +// 'a' = active (funds locked) +// 'd' = disputed +// 'r' = released to seller +// 'f' = refunded to buyer +// +// The contract treasury holds the locked funds during active/disputed state. +package main + +import ( + "fmt" + "os" +) + +// ── LEB128 & builders ──────────────────────────────────────────────────────── + +func u(v uint64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + if v != 0 { + bt |= 0x80 + } + b = append(b, bt) + if v == 0 { + return b + } + } +} + +func s(v int64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + sign := (bt & 0x40) != 0 + if (v == 0 && !sign) || (v == -1 && sign) { + return append(b, bt) + } + b = append(b, bt|0x80) + } +} + +func cat(slices ...[]byte) []byte { + var out []byte + for _, sl := range slices { + out = append(out, sl...) + } + return out +} + +func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) } + +func section(id byte, content []byte) []byte { + return cat([]byte{id}, u(uint64(len(content))), content) +} + +func vec(items ...[]byte) []byte { + out := u(uint64(len(items))) + for _, it := range items { + out = append(out, it...) + } + return out +} + +func functype(params, results []byte) []byte { + return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results) +} + +func importFunc(mod, name string, typeIdx uint32) []byte { + return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx))) +} + +func exportEntry(name string, kind byte, idx uint32) []byte { + return cat(wstr(name), []byte{kind}, u(uint64(idx))) +} + +func dataSegment(offset int32, data []byte) []byte { + return cat( + []byte{0x00}, + []byte{0x41}, s(int64(offset)), []byte{0x0B}, + u(uint64(len(data))), data, + ) +} + +func funcBody(localDecls []byte, instrs ...[]byte) []byte { + inner := cat(localDecls) + for _, ins := range instrs { + inner = append(inner, ins...) + } + inner = append(inner, 0x0B) + return cat(u(uint64(len(inner))), inner) +} + +var noLocals = u(0) + +func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) } +func withLocals(decls ...[]byte) []byte { + return cat(u(uint64(len(decls))), cat(decls...)) +} + +const ( + tI32 byte = 0x7F + tI64 byte = 0x7E +) + +func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) } +func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) } +func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) } +func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) } +func ic64(v int64) []byte { return cat([]byte{0x42}, s(v)) } +func block_() []byte { return []byte{0x02, 0x40} } +func loop_() []byte { return []byte{0x03, 0x40} } +func if_() []byte { return []byte{0x04, 0x40} } +func ifI32() []byte { return []byte{0x04, tI32} } +func else_() []byte { return []byte{0x05} } +func end_() []byte { return []byte{0x0B} } +func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) } +func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) } +func return_() []byte { return []byte{0x0F} } +func drop() []byte { return []byte{0x1A} } +func i32Eqz() []byte { return []byte{0x45} } +func i32Ne() []byte { return []byte{0x47} } +func i32GtU() []byte { return []byte{0x4B} } +func i32GeU() []byte { return []byte{0x4F} } +func i32Add() []byte { return []byte{0x6A} } +func i64Eqz() []byte { return []byte{0x50} } +func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} } +func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} } + +// ── Memory layout ───────────────────────────────────────────────────────────── +// +// 0x000 64 arg[0]: escrow id +// 0x040 128 arg[1]: seller address or winner string +// 0x140 128 caller buffer +// 0x1C0 64 treasury buffer +// 0x200 128 state-read buffer 1 (buyer or seller) +// 0x280 128 state-read buffer 2 (seller or admin) +// 0x300 2 status byte buffer +// 0x310 256 scratch (buildField key workspace) +// +// Constants at 0x500+: +// 0x500 5 "admin" +// 0x506 13 "initialized: " +// 0x514 10 "created: " +// 0x51E 10 "released: " +// 0x529 10 "refunded: " +// 0x534 10 "disputed: " +// 0x53F 10 "resolved: " +// 0x54A 14 "unauthorized: " +// 0x559 11 "not found: " +// 0x565 14 "already init: " +// 0x574 12 "not active: " +// 0x581 14 "not disputed: " +// 0x590 7 "buyer: " +// 0x598 8 "seller: " +// 0x5A1 8 "status: " +// 0x5AA 7 "active" +// 0x5B1 9 "disputed" +// 0x5BB 9 "released" +// 0x5C5 8 "refunded" +// 0x5CE 6 "buyer" (for resolve winner comparison) +// 0x5D4 6 "seller" + +const ( + offArg0 int32 = 0x000 + offArg1 int32 = 0x040 + offCaller int32 = 0x140 + offTreasury int32 = 0x1C0 + offRead1 int32 = 0x200 + offRead2 int32 = 0x280 + offStatBuf int32 = 0x300 + offScratch int32 = 0x310 + + offKeyAdmin int32 = 0x500 + offInitedPfx int32 = 0x506 + offCreatedPfx int32 = 0x514 + offReleasedPfx int32 = 0x51E + offRefundedPfx int32 = 0x529 + offDisputedPfx int32 = 0x534 + offResolvedPfx int32 = 0x53F + offUnauthPfx int32 = 0x54A + offNotFoundPfx int32 = 0x559 + offAlreadyPfx int32 = 0x565 + offNotActivePfx int32 = 0x574 + offNotDispPfx int32 = 0x581 + offBuyerPfx int32 = 0x590 + offSellerPfx int32 = 0x598 + offStatusPfx int32 = 0x5A1 + offStrActive int32 = 0x5AA + offStrDisputed int32 = 0x5B1 + offStrReleased int32 = 0x5BB + offStrRefunded int32 = 0x5C5 + offStrBuyer int32 = 0x5CE + offStrSeller int32 = 0x5D4 +) + +// ── Import / function indices ───────────────────────────────────────────────── + +const ( + fnGetArgStr = 0 + fnGetArgU64 = 1 + fnGetCaller = 2 + fnGetState = 3 + fnSetState = 4 + fnLog = 5 + fnTransfer = 6 + fnGetContractTreasury = 7 + fnPutU64 = 8 + fnGetU64 = 9 + + fnBytesEqual = 10 + fnMemcpy = 11 + fnLogPrefix = 12 + fnBuildField = 13 // (idOff, idLen, fieldChar i32) → keyLen i32 + fnInit = 14 + fnCreate = 15 + fnRelease = 16 + fnRefund = 17 + fnDispute = 18 + fnResolve = 19 + fnInfo = 20 +) + +// ── Helper bodies ───────────────────────────────────────────────────────────── + +func bytesEqualBody() []byte { + return funcBody( + withLocals(localDecl(2, tI32)), + ic32(1), lset(4), ic32(0), lset(3), + block_(), loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), + lget(0), lget(3), i32Add(), i32Load8U(), + lget(1), lget(3), i32Add(), i32Load8U(), + i32Ne(), if_(), ic32(0), lset(4), br_(2), end_(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), end_(), end_(), + lget(4), + ) +} + +func memcpyBody() []byte { + return funcBody( + withLocals(localDecl(1, tI32)), + ic32(0), lset(3), + block_(), loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), + lget(0), lget(3), i32Add(), + lget(1), lget(3), i32Add(), i32Load8U(), + i32Store8(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), end_(), end_(), + ) +} + +func logPrefixBody() []byte { + // (prefixPtr, prefixLen, suffixPtr, suffixLen i32) + return funcBody( + noLocals, + ic32(offScratch), lget(0), lget(1), call(fnMemcpy), + ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy), + ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog), + ) +} + +// buildField(idOff, idLen, fieldChar i32) → keyLen i32 +// Writes "e::" into offScratch. +func buildFieldBody() []byte { + return funcBody( + noLocals, + ic32(offScratch), ic32('e'), i32Store8(), + ic32(offScratch+1), ic32(':'), i32Store8(), + ic32(offScratch+2), lget(0), lget(1), call(fnMemcpy), + ic32(offScratch+2), lget(1), i32Add(), ic32(':'), i32Store8(), + ic32(offScratch+3), lget(1), i32Add(), lget(2), i32Store8(), + lget(1), ic32(4), i32Add(), + ) +} + +// isCallerAdminCode: reads admin into offRead2, checks caller == admin. +// Params: callerLen local index, adminLen local index. +// Returns inline code that leaves i32 (1=is admin) on stack. +func isCallerAdminCode(callerLenLocal, adminLenLocal uint32) []byte { + return cat( + ic32(offKeyAdmin), ic32(5), ic32(offRead2), ic32(128), call(fnGetState), lset(adminLenLocal), + lget(adminLenLocal), i32Eqz(), + ifI32(), ic32(0), else_(), + lget(callerLenLocal), lget(adminLenLocal), i32Ne(), + ifI32(), ic32(0), else_(), + ic32(offCaller), ic32(offRead2), lget(callerLenLocal), call(fnBytesEqual), + end_(), end_(), + ) +} + +// ── Contract method bodies ──────────────────────────────────────────────────── + +// init() — set caller as admin +// Locals: callerLen(0), existingLen(1) +func initBody() []byte { + return funcBody( + withLocals(localDecl(2, tI32)), + ic32(offCaller), ic32(128), call(fnGetCaller), lset(0), + ic32(offKeyAdmin), ic32(5), ic32(offRead2), ic32(128), call(fnGetState), lset(1), + lget(1), ic32(0), i32GtU(), if_(), + ic32(offAlreadyPfx), ic32(14), ic32(offCaller), lget(0), call(fnLogPrefix), + return_(), + end_(), + ic32(offKeyAdmin), ic32(5), ic32(offCaller), lget(0), call(fnSetState), + ic32(offInitedPfx), ic32(13), ic32(offCaller), lget(0), call(fnLogPrefix), + ) +} + +// create(id, seller, amount u64) +// Locals: idLen(0), sellerLen(1), callerLen(2), treasuryLen(3), keyLen(4) [i32] +// amount(5) [i64] +func createBody() []byte { + return funcBody( + withLocals(localDecl(5, tI32), localDecl(1, tI64)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1), + lget(1), i32Eqz(), if_(), return_(), end_(), + ic32(2), call(fnGetArgU64), lset(5), + + // Check id not already used + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offStatBuf), ic32(2), call(fnGetState), + ic32(0), i32GtU(), if_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Get caller (buyer) and treasury + ic32(offCaller), ic32(128), call(fnGetCaller), lset(2), + ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(3), + + // Transfer amount: buyer → treasury + ic32(offCaller), lget(2), ic32(offTreasury), lget(3), lget(5), call(fnTransfer), drop(), + + // Write buyer: e::b → caller + ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offCaller), lget(2), call(fnSetState), + + // Write seller: e::s → arg1 + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offArg1), lget(1), call(fnSetState), + + // Write amount: e::v + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), lget(5), call(fnPutU64), + + // Write status = 'a': e::x + ic32(offStatBuf), ic32('a'), i32Store8(), + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offStatBuf), ic32(1), call(fnSetState), + + ic32(offCreatedPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// checkActiveStatus: reads status byte into offStatBuf; returns inline code +// that returns with an error log if status != expectedChar. +func checkStatus(idLenLocal uint32, expectedChar int32, errPfxOff int32, errPfxLen int32) []byte { + return cat( + ic32(offArg0), lget(idLenLocal), ic32('x'), call(fnBuildField), + // keyLen on stack — but we need it in a local. Use the call result directly: + // Actually we need to save it. This is getting complex. Let me inline differently. + // Instead, we'll read status without saving keyLen — just check immediately. + // Actually the fn returns keyLen on stack. We need (keyPtr, keyLen, dstPtr, dstLen) for get_state. + // So: ic32(offScratch), [keyLen], ic32(offStatBuf), ic32(2), call(fnGetState) + // But keyLen is already on the stack after buildField! + // We can do: ic32(offScratch), , ... + // No wait, stack ordering: we push ic32(offScratch) BEFORE the keyLen. + // For get_state(keyPtr, keyLen, dstPtr, dstLen): + // We need: keyPtr first, then keyLen. + // buildField leaves keyLen on stack, but we need keyPtr first. + // This is the fundamental issue: the keyPtr (offScratch) needs to be pushed before keyLen. + // + // Fix: save keyLen to a local first, then push offScratch, keyLen. + // But we don't have a spare local in this helper... + // + // For now, let me just not use this helper function and inline the check in each method. + ic32(0), // placeholder - don't use this helper + ) +} + +// release(id) — buyer releases funds to seller +// Locals: idLen(0), buyerLen(1), sellerLen(2), callerLen(3), treasuryLen(4), keyLen(5) [i32] +// amount(6) [i64] +func releaseBody() []byte { + return funcBody( + withLocals(localDecl(6, tI32), localDecl(1, tI64)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Check status == 'a' + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), ic32(offStatBuf), ic32(2), call(fnGetState), + ic32(0), i32Ne(), if_(), + ic32(offStatBuf), i32Load8U(), ic32('a'), i32Ne(), if_(), + ic32(offNotActivePfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + else_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Check caller == buyer + ic32(offCaller), ic32(128), call(fnGetCaller), lset(3), + ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), ic32(offRead1), ic32(128), call(fnGetState), lset(1), + lget(3), lget(1), i32Ne(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + ic32(offCaller), ic32(offRead1), lget(3), call(fnBytesEqual), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Get seller, treasury, amount + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), ic32(offRead2), ic32(128), call(fnGetState), lset(2), + ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(4), + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), call(fnGetU64), lset(6), + + // Transfer: treasury → seller + ic32(offTreasury), lget(4), ic32(offRead2), lget(2), lget(6), call(fnTransfer), drop(), + + // Mark status = 'r' + ic32(offStatBuf), ic32('r'), i32Store8(), + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), ic32(offStatBuf), ic32(1), call(fnSetState), + + ic32(offReleasedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// refund(id) — seller voluntarily refunds buyer +// Locals: idLen(0), sellerLen(1), buyerLen(2), callerLen(3), treasuryLen(4), keyLen(5) [i32] +// amount(6) [i64] +func refundBody() []byte { + return funcBody( + withLocals(localDecl(6, tI32), localDecl(1, tI64)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Check status == 'a' + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), ic32(offStatBuf), ic32(2), call(fnGetState), + ic32(0), i32Ne(), if_(), + ic32(offStatBuf), i32Load8U(), ic32('a'), i32Ne(), if_(), + ic32(offNotActivePfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + else_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Check caller == seller + ic32(offCaller), ic32(128), call(fnGetCaller), lset(3), + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), ic32(offRead1), ic32(128), call(fnGetState), lset(1), + lget(3), lget(1), i32Ne(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + ic32(offCaller), ic32(offRead1), lget(3), call(fnBytesEqual), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Get buyer, treasury, amount + ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), ic32(offRead2), ic32(128), call(fnGetState), lset(2), + ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(4), + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), call(fnGetU64), lset(6), + + // Transfer: treasury → buyer + ic32(offTreasury), lget(4), ic32(offRead2), lget(2), lget(6), call(fnTransfer), drop(), + + // Mark status = 'f' + ic32(offStatBuf), ic32('f'), i32Store8(), + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(5), + ic32(offScratch), lget(5), ic32(offStatBuf), ic32(1), call(fnSetState), + + ic32(offRefundedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// dispute(id) — buyer or seller raises a dispute +// Locals: idLen(0), callerLen(1), buyerLen(2), sellerLen(3), keyLen(4) [i32] +func disputeBody() []byte { + return funcBody( + withLocals(localDecl(5, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Check status == 'a' + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offStatBuf), ic32(2), call(fnGetState), + ic32(0), i32Ne(), if_(), + ic32(offStatBuf), i32Load8U(), ic32('a'), i32Ne(), if_(), + ic32(offNotActivePfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + else_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Caller must be buyer or seller + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offRead1), ic32(128), call(fnGetState), lset(2), + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offRead2), ic32(128), call(fnGetState), lset(3), + + // isBuyer = callerLen==buyerLen && bytes_equal(caller, buyer, callerLen) + // isSeller = callerLen==sellerLen && bytes_equal(caller, seller, callerLen) + // if !isBuyer && !isSeller: unauthorized + lget(1), lget(2), i32Ne(), + if_(), + // callerLen != buyerLen → check seller + lget(1), lget(3), i32Ne(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + ic32(offCaller), ic32(offRead2), lget(1), call(fnBytesEqual), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + else_(), + // callerLen == buyerLen → check bytes + ic32(offCaller), ic32(offRead1), lget(1), call(fnBytesEqual), + i32Eqz(), if_(), + // not buyer — check seller + lget(1), lget(3), i32Ne(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + ic32(offCaller), ic32(offRead2), lget(1), call(fnBytesEqual), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + end_(), + end_(), + + // Mark status = 'd' + ic32(offStatBuf), ic32('d'), i32Store8(), + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(4), + ic32(offScratch), lget(4), ic32(offStatBuf), ic32(1), call(fnSetState), + ic32(offDisputedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// resolve(id, winner) — admin resolves disputed escrow +// winner arg must be "buyer" or "seller" +// Locals: idLen(0), winnerLen(1), callerLen(2), adminLen(3), +// recipientLen(4), treasuryLen(5), keyLen(6) [i32] +// amount(7) [i64] +func resolveBody() []byte { + return funcBody( + withLocals(localDecl(7, tI32), localDecl(1, tI64)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1), + lget(1), i32Eqz(), if_(), return_(), end_(), + + // Check status == 'd' + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(6), + ic32(offScratch), lget(6), ic32(offStatBuf), ic32(2), call(fnGetState), + ic32(0), i32Ne(), if_(), + ic32(offStatBuf), i32Load8U(), ic32('d'), i32Ne(), if_(), + ic32(offNotDispPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + else_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Check caller is admin + ic32(offCaller), ic32(128), call(fnGetCaller), lset(2), + isCallerAdminCode(2, 3), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Determine recipient based on winner arg + // winner == "buyer" (5 bytes) → recipient = buyer address + // winner == "seller" (6 bytes) → recipient = seller address + ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(5), + ic32(offArg0), lget(0), ic32('v'), call(fnBuildField), lset(6), + ic32(offScratch), lget(6), call(fnGetU64), lset(7), + + // Compare winner to "buyer" + lget(1), ic32(5), i32Ne(), if_(), + // not "buyer" — assume "seller" + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(6), + ic32(offScratch), lget(6), ic32(offRead1), ic32(128), call(fnGetState), lset(4), + else_(), + // might be "buyer" — verify bytes + ic32(offArg1), ic32(offStrBuyer), ic32(5), call(fnBytesEqual), + i32Eqz(), if_(), + // not "buyer" bytes — default to seller + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(6), + ic32(offScratch), lget(6), ic32(offRead1), ic32(128), call(fnGetState), lset(4), + else_(), + ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(6), + ic32(offScratch), lget(6), ic32(offRead1), ic32(128), call(fnGetState), lset(4), + end_(), + end_(), + + // Transfer: treasury → recipient + ic32(offTreasury), lget(5), ic32(offRead1), lget(4), lget(7), call(fnTransfer), drop(), + + // Mark status = 'r' (released, settled) + ic32(offStatBuf), ic32('r'), i32Store8(), + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(6), + ic32(offScratch), lget(6), ic32(offStatBuf), ic32(1), call(fnSetState), + + ic32(offResolvedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefix), + ) +} + +// info(id) — log escrow details +// Locals: idLen(0), buyerLen(1), sellerLen(2), keyLen(3) [i32] +func infoBody() []byte { + return funcBody( + withLocals(localDecl(4, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Check exists + ic32(offArg0), lget(0), ic32('x'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offStatBuf), ic32(2), call(fnGetState), + ic32(0), i32Ne(), i32Eqz(), if_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefix), + return_(), + end_(), + + // Log buyer + ic32(offArg0), lget(0), ic32('b'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offRead1), ic32(128), call(fnGetState), lset(1), + ic32(offBuyerPfx), ic32(7), ic32(offRead1), lget(1), call(fnLogPrefix), + + // Log seller + ic32(offArg0), lget(0), ic32('s'), call(fnBuildField), lset(3), + ic32(offScratch), lget(3), ic32(offRead2), ic32(128), call(fnGetState), lset(2), + ic32(offSellerPfx), ic32(8), ic32(offRead2), lget(2), call(fnLogPrefix), + + // Log status + ic32(offStatBuf), i32Load8U(), ic32('a'), i32Ne(), if_(), + ic32(offStatBuf), i32Load8U(), ic32('d'), i32Ne(), if_(), + ic32(offStatBuf), i32Load8U(), ic32('r'), i32Ne(), if_(), + ic32(offStatusPfx), ic32(8), ic32(offStrRefunded), ic32(8), call(fnLogPrefix), + else_(), + ic32(offStatusPfx), ic32(8), ic32(offStrReleased), ic32(8), call(fnLogPrefix), + end_(), + else_(), + ic32(offStatusPfx), ic32(8), ic32(offStrDisputed), ic32(8), call(fnLogPrefix), + end_(), + else_(), + ic32(offStatusPfx), ic32(8), ic32(offStrActive), ic32(6), call(fnLogPrefix), + end_(), + ) +} + +// ── main ────────────────────────────────────────────────────────────────────── + +func main() { + // Types: + // 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal, buildField + // 1: (i32)→(i64) get_arg_u64 + // 2: (i32,i32,i32,i32)→(i32) get_state + // 3: (i32,i32,i32,i32)→() set_state, log_prefix + // 4: (i32,i32)→() log + // 5: (i32,i32,i32,i32,i64)→(i32) transfer + // 6: (i32,i32)→(i32) get_caller, get_contract_treasury + // 7: (i32,i32,i64)→() put_u64 + // 8: (i32,i32)→(i64) get_u64 + // 9: ()→() exported methods + // 10: (i32,i32,i32)→() memcpy + typeSection := section(0x01, vec( + functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0 + functype([]byte{tI32}, []byte{tI64}), // 1 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3 + functype([]byte{tI32, tI32}, []byte{}), // 4 + functype([]byte{tI32, tI32, tI32, tI32, tI64}, []byte{tI32}), // 5 + functype([]byte{tI32, tI32}, []byte{tI32}), // 6 + functype([]byte{tI32, tI32, tI64}, []byte{}), // 7 + functype([]byte{tI32, tI32}, []byte{tI64}), // 8 + functype([]byte{}, []byte{}), // 9 + functype([]byte{tI32, tI32, tI32}, []byte{}), // 10 + )) + + importSection := section(0x02, vec( + importFunc("env", "get_arg_str", 0), // 0 + importFunc("env", "get_arg_u64", 1), // 1 + importFunc("env", "get_caller", 6), // 2 + importFunc("env", "get_state", 2), // 3 + importFunc("env", "set_state", 3), // 4 + importFunc("env", "log", 4), // 5 + importFunc("env", "transfer", 5), // 6 + importFunc("env", "get_contract_treasury", 6), // 7 + importFunc("env", "put_u64", 7), // 8 + importFunc("env", "get_u64", 8), // 9 + )) + + // 11 local functions + functionSection := section(0x03, vec( + u(0), // bytes_equal type 0 + u(10), // memcpy type 10 + u(3), // log_prefix type 3 + u(0), // build_field type 0 + u(9), // init type 9 + u(9), // create type 9 + u(9), // release type 9 + u(9), // refund type 9 + u(9), // dispute type 9 + u(9), // resolve type 9 + u(9), // info type 9 + )) + + memorySection := section(0x05, vec(cat([]byte{0x00}, u(2)))) // 2 pages + + exportSection := section(0x07, vec( + exportEntry("memory", 0x02, 0), + exportEntry("init", 0x00, fnInit), + exportEntry("create", 0x00, fnCreate), + exportEntry("release", 0x00, fnRelease), + exportEntry("refund", 0x00, fnRefund), + exportEntry("dispute", 0x00, fnDispute), + exportEntry("resolve", 0x00, fnResolve), + exportEntry("info", 0x00, fnInfo), + )) + + dataSection := section(0x0B, cat( + u(21), + dataSegment(offKeyAdmin, []byte("admin")), + dataSegment(offInitedPfx, []byte("initialized: ")), + dataSegment(offCreatedPfx, []byte("created: ")), + dataSegment(offReleasedPfx, []byte("released: ")), + dataSegment(offRefundedPfx, []byte("refunded: ")), + dataSegment(offDisputedPfx, []byte("disputed: ")), + dataSegment(offResolvedPfx, []byte("resolved: ")), + dataSegment(offUnauthPfx, []byte("unauthorized: ")), + dataSegment(offNotFoundPfx, []byte("not found: ")), + dataSegment(offAlreadyPfx, []byte("already init: ")), + dataSegment(offNotActivePfx,[]byte("not active: ")), + dataSegment(offNotDispPfx, []byte("not disputed: ")), + dataSegment(offBuyerPfx, []byte("buyer: ")), + dataSegment(offSellerPfx, []byte("seller: ")), + dataSegment(offStatusPfx, []byte("status: ")), + dataSegment(offStrActive, []byte("active")), + dataSegment(offStrDisputed, []byte("disputed")), + dataSegment(offStrReleased, []byte("released")), + dataSegment(offStrRefunded, []byte("refunded")), + dataSegment(offStrBuyer, []byte("buyer")), + dataSegment(offStrSeller, []byte("seller")), + )) + + codeSection := section(0x0A, cat( + u(11), + bytesEqualBody(), + memcpyBody(), + logPrefixBody(), + buildFieldBody(), + initBody(), + createBody(), + releaseBody(), + refundBody(), + disputeBody(), + resolveBody(), + infoBody(), + )) + + module := cat( + []byte{0x00, 0x61, 0x73, 0x6d}, + []byte{0x01, 0x00, 0x00, 0x00}, + typeSection, + importSection, + functionSection, + memorySection, + exportSection, + dataSection, + codeSection, + ) + + out := "contracts/escrow/escrow.wasm" + if err := os.WriteFile(out, module, 0644); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } + fmt.Printf("Written %s (%d bytes)\n", out, len(module)) +} diff --git a/contracts/governance/gen/main.go b/contracts/governance/gen/main.go new file mode 100644 index 0000000..0a88640 --- /dev/null +++ b/contracts/governance/gen/main.go @@ -0,0 +1,538 @@ +// gen generates contracts/governance/governance.wasm +// Run from repo root: go run ./contracts/governance/gen/ +// +// Methods: init, propose, approve, reject, get, get_pending, set_admin +// +// State layout (all keys are raw UTF-8 strings): +// "admin" → admin address bytes +// "param:" → current live value bytes +// "prop:" → pending proposed value bytes +// +// Access control: +// approve / reject / set_admin → admin only +// propose / get / get_pending → anyone +// init → anyone (but only writes if admin not set) +package main + +import ( + "fmt" + "os" +) + +// ── LEB128 ─────────────────────────────────────────────────────────────────── + +func u(v uint64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + if v != 0 { + bt |= 0x80 + } + b = append(b, bt) + if v == 0 { + return b + } + } +} + +func s(v int64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + sign := (bt & 0x40) != 0 + if (v == 0 && !sign) || (v == -1 && sign) { + return append(b, bt) + } + b = append(b, bt|0x80) + } +} + +func cat(slices ...[]byte) []byte { + var out []byte + for _, sl := range slices { + out = append(out, sl...) + } + return out +} + +func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) } + +func section(id byte, content []byte) []byte { + return cat([]byte{id}, u(uint64(len(content))), content) +} + +func vec(items ...[]byte) []byte { + out := u(uint64(len(items))) + for _, it := range items { + out = append(out, it...) + } + return out +} + +func functype(params, results []byte) []byte { + return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results) +} + +func importFunc(mod, name string, typeIdx uint32) []byte { + return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx))) +} + +func exportEntry(name string, kind byte, idx uint32) []byte { + return cat(wstr(name), []byte{kind}, u(uint64(idx))) +} + +func dataSegment(offset int32, data []byte) []byte { + return cat( + []byte{0x00}, + []byte{0x41}, s(int64(offset)), []byte{0x0B}, + u(uint64(len(data))), data, + ) +} + +func funcBody(localDecls []byte, instrs ...[]byte) []byte { + inner := cat(localDecls) + for _, ins := range instrs { + inner = append(inner, ins...) + } + inner = append(inner, 0x0B) + return cat(u(uint64(len(inner))), inner) +} + +var noLocals = u(0) + +func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) } +func withLocals(decls ...[]byte) []byte { + return cat(u(uint64(len(decls))), cat(decls...)) +} + +const ( + tI32 byte = 0x7F + tI64 byte = 0x7E +) + +func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) } +func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) } +func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) } +func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) } +func block_() []byte { return []byte{0x02, 0x40} } +func loop_() []byte { return []byte{0x03, 0x40} } +func if_() []byte { return []byte{0x04, 0x40} } +func ifI32() []byte { return []byte{0x04, tI32} } +func else_() []byte { return []byte{0x05} } +func end_() []byte { return []byte{0x0B} } +func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) } +func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) } +func return_() []byte { return []byte{0x0F} } +func i32Eqz() []byte { return []byte{0x45} } +func i32Ne() []byte { return []byte{0x47} } +func i32GtU() []byte { return []byte{0x4B} } +func i32GeU() []byte { return []byte{0x4F} } +func i32Add() []byte { return []byte{0x6A} } +func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} } +func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} } + +// ── Memory layout ───────────────────────────────────────────────────────────── +// +// 0x000 64 arg[0] key buffer +// 0x040 256 arg[1] value buffer +// 0x140 128 caller buffer +// 0x1C0 128 admin state-read buffer +// 0x240 256 value state-read buffer +// 0x340 256 scratch (build_key writes here, log_prefix_name writes here) +// +// Constant strings: +// 0x500 6 "admin" (state key) +// 0x506 6 "param:" (state key prefix) +// 0x50D 5 "prop:" (state key prefix) +// 0x513 13 "initialized: " +// 0x521 9 "proposed: " +// 0x52B 10 "approved: " +// 0x536 10 "rejected: " +// 0x541 8 "value: " +// 0x549 11 "not set: " +// 0x555 9 "pending: " +// 0x55F 12 "no pending: " +// 0x56C 7 "admin: " +// 0x574 14 "unauthorized: " +// 0x583 14 "already init: " + +const ( + offArg0 int32 = 0x000 + offArg1 int32 = 0x040 + offCaller int32 = 0x140 + offAdminRead int32 = 0x1C0 + offValRead int32 = 0x240 + offScratch int32 = 0x340 + + offKeyAdmin int32 = 0x500 // "admin" 5 bytes + offPfxParam int32 = 0x506 // "param:" 6 bytes + offPfxProp int32 = 0x50D // "prop:" 5 bytes + offInitedPfx int32 = 0x513 // "initialized: " 14 + offProposedPfx int32 = 0x521 // "proposed: " 9 (key=val shown as arg0=arg1) + offApprovedPfx int32 = 0x52B // "approved: " 10 + offRejectedPfx int32 = 0x536 // "rejected: " 10 + offValuePfx int32 = 0x541 // "value: " 7 + offNotSetPfx int32 = 0x549 // "not set: " 9 + offPendingPfx int32 = 0x555 // "pending: " 9 + offNoPendingPfx int32 = 0x55F // "no pending: " 12 + offAdminPfx int32 = 0x56C // "admin: " 7 + offUnauthPfx int32 = 0x574 // "unauthorized: " 14 + offAlreadyPfx int32 = 0x583 // "already init" 12 +) + +const ( + fnGetArgStr = 0 + fnGetCaller = 1 + fnGetState = 2 + fnSetState = 3 + fnLog = 4 + + fnBytesEqual = 5 + fnMemcpy = 6 + fnLogPrefixName = 7 + fnBuildKey = 8 + fnInit = 9 + fnPropose = 10 + fnApprove = 11 + fnReject = 12 + fnGet = 13 + fnGetPending = 14 + fnSetAdmin = 15 +) + +// ── Helper bodies ───────────────────────────────────────────────────────────── + +func bytesEqualBody() []byte { + return funcBody( + withLocals(localDecl(2, tI32)), + ic32(1), lset(4), ic32(0), lset(3), + block_(), loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), + lget(0), lget(3), i32Add(), i32Load8U(), + lget(1), lget(3), i32Add(), i32Load8U(), + i32Ne(), if_(), ic32(0), lset(4), br_(2), end_(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), end_(), end_(), + lget(4), + ) +} + +func memcpyBody() []byte { + return funcBody( + withLocals(localDecl(1, tI32)), + ic32(0), lset(3), + block_(), loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), + lget(0), lget(3), i32Add(), + lget(1), lget(3), i32Add(), i32Load8U(), + i32Store8(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), end_(), end_(), + ) +} + +func logPrefixNameBody() []byte { + return funcBody( + noLocals, + ic32(offScratch), lget(0), lget(1), call(fnMemcpy), + ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy), + ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog), + ) +} + +// $build_key(pfxOff, pfxLen, dataOff, dataLen i32) → keyLen i32 +func buildKeyBody() []byte { + return funcBody( + noLocals, + ic32(offScratch), lget(0), lget(1), call(fnMemcpy), + ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy), + lget(1), lget(3), i32Add(), + ) +} + +// isCallerAdmin: reads admin from state into offAdminRead, returns 1 if caller==admin. +// (callerLenLocal i32) → i32 +// Leaves adminLen on stack as side effect (stored in local adminLenLocal). +// Emits code that: reads admin, compares with caller. +// Uses offAdminRead for the state read buffer. +func isCallerAdminCode(callerLenLocal, adminLenLocal uint32) []byte { + return cat( + // adminLen = get_state("admin", 5, offAdminRead, 128) + ic32(offKeyAdmin), ic32(5), ic32(offAdminRead), ic32(128), call(fnGetState), lset(adminLenLocal), + // if adminLen == 0: not initialized → return 0 + lget(adminLenLocal), i32Eqz(), + ifI32(), + ic32(0), + else_(), + // bytes_equal(offCaller, offAdminRead, callerLen) if callerLen==adminLen else 0 + lget(callerLenLocal), lget(adminLenLocal), i32Ne(), + ifI32(), ic32(0), else_(), + ic32(offCaller), ic32(offAdminRead), lget(callerLenLocal), call(fnBytesEqual), + end_(), + end_(), + ) +} + +// ── Contract method bodies ──────────────────────────────────────────────────── + +// init() — set caller as admin (only if not already initialized) +func initBody() []byte { + return funcBody( + withLocals(localDecl(2, tI32)), + // callerLen = get_caller(offCaller, 128) + ic32(offCaller), ic32(128), call(fnGetCaller), lset(0), + // Check if admin already set + ic32(offKeyAdmin), ic32(5), ic32(offAdminRead), ic32(128), call(fnGetState), lset(1), + lget(1), ic32(0), i32GtU(), if_(), + ic32(offAlreadyPfx), ic32(12), ic32(offCaller), lget(0), call(fnLogPrefixName), + return_(), + end_(), + // set state["admin"] = caller + ic32(offKeyAdmin), ic32(5), ic32(offCaller), lget(0), call(fnSetState), + ic32(offInitedPfx), ic32(14), ic32(offCaller), lget(0), call(fnLogPrefixName), + ) +} + +// propose(key, value) — store pending proposal +// Locals: keyLen(0), valLen(1), keyBufLen(2) +func proposeBody() []byte { + return funcBody( + withLocals(localDecl(3, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + ic32(1), ic32(offArg1), ic32(256), call(fnGetArgStr), lset(1), + lget(1), i32Eqz(), if_(), return_(), end_(), + + // set state["prop:"] = value + ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(2), + ic32(offScratch), lget(2), ic32(offArg1), lget(1), call(fnSetState), + + // log "proposed: " + ic32(offProposedPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) +} + +// approve(key) — admin: move prop: → param: +// Locals: keyLen(0), callerLen(1), adminLen(2), propLen(3), keyBufLen(4) +func approveBody() []byte { + return funcBody( + withLocals(localDecl(5, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + isCallerAdminCode(1, 2), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + // Read pending value: get_state("prop:", offValRead, 256) + ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offValRead), ic32(256), call(fnGetState), lset(3), + lget(3), i32Eqz(), if_(), + ic32(offNoPendingPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + // set state["param:"] = offValRead[0..propLen) + ic32(offPfxParam), ic32(6), ic32(offArg0), lget(0), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offValRead), lget(3), call(fnSetState), + + // delete pending: set state["prop:"] = "" + ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offScratch), ic32(0), call(fnSetState), + + ic32(offApprovedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) +} + +// reject(key) — admin: delete prop: +// Locals: keyLen(0), callerLen(1), adminLen(2), keyBufLen(3) +func rejectBody() []byte { + return funcBody( + withLocals(localDecl(4, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + isCallerAdminCode(1, 2), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + // delete pending + ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(3), + ic32(offScratch), lget(3), ic32(offScratch), ic32(0), call(fnSetState), + ic32(offRejectedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) +} + +// get(key) — read live parameter value +// Locals: keyLen(0), valLen(1), keyBufLen(2) +func getBody() []byte { + return funcBody( + withLocals(localDecl(3, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + ic32(offPfxParam), ic32(6), ic32(offArg0), lget(0), call(fnBuildKey), lset(2), + ic32(offScratch), lget(2), ic32(offValRead), ic32(256), call(fnGetState), lset(1), + lget(1), i32Eqz(), if_(), + ic32(offNotSetPfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + ic32(offValuePfx), ic32(7), ic32(offValRead), lget(1), call(fnLogPrefixName), + ) +} + +// get_pending(key) — read pending proposal +// Locals: keyLen(0), valLen(1), keyBufLen(2) +func getPendingBody() []byte { + return funcBody( + withLocals(localDecl(3, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + ic32(offPfxProp), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(2), + ic32(offScratch), lget(2), ic32(offValRead), ic32(256), call(fnGetState), lset(1), + lget(1), i32Eqz(), if_(), + ic32(offNoPendingPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + ic32(offPendingPfx), ic32(9), ic32(offValRead), lget(1), call(fnLogPrefixName), + ) +} + +// set_admin(new_admin) — transfer admin role +// Locals: newAdminLen(0), callerLen(1), adminLen(2) +func setAdminBody() []byte { + return funcBody( + withLocals(localDecl(3, tI32)), + ic32(0), ic32(offArg0), ic32(128), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + isCallerAdminCode(1, 2), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + ic32(offKeyAdmin), ic32(5), ic32(offArg0), lget(0), call(fnSetState), + ic32(offAdminPfx), ic32(7), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) +} + +// ── main ────────────────────────────────────────────────────────────────────── + +func main() { + // Types: + // 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal + // 1: (i32,i32)→(i32) get_caller + // 2: (i32,i32,i32,i32)→(i32) get_state, build_key + // 3: (i32,i32,i32,i32)→() set_state, log_prefix_name + // 4: (i32,i32)→() log + // 5: ()→() exported methods + // 6: (i32,i32,i32)→() memcpy + typeSection := section(0x01, vec( + functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0 + functype([]byte{tI32, tI32}, []byte{tI32}), // 1 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3 + functype([]byte{tI32, tI32}, []byte{}), // 4 + functype([]byte{}, []byte{}), // 5 + functype([]byte{tI32, tI32, tI32}, []byte{}), // 6 + )) + + importSection := section(0x02, vec( + importFunc("env", "get_arg_str", 0), // 0 type 0 + importFunc("env", "get_caller", 1), // 1 type 1 + importFunc("env", "get_state", 2), // 2 type 2 + importFunc("env", "set_state", 3), // 3 type 3 + importFunc("env", "log", 4), // 4 type 4 + )) + + // 11 local functions + functionSection := section(0x03, vec( + u(0), // bytes_equal type 0 + u(6), // memcpy type 6 + u(3), // log_prefix_name type 3 + u(2), // build_key type 2 + u(5), // init type 5 + u(5), // propose type 5 + u(5), // approve type 5 + u(5), // reject type 5 + u(5), // get type 5 + u(5), // get_pending type 5 + u(5), // set_admin type 5 + )) + + memorySection := section(0x05, vec(cat([]byte{0x00}, u(2)))) // 2 pages = 128KB + + exportSection := section(0x07, vec( + exportEntry("memory", 0x02, 0), + exportEntry("init", 0x00, fnInit), + exportEntry("propose", 0x00, fnPropose), + exportEntry("approve", 0x00, fnApprove), + exportEntry("reject", 0x00, fnReject), + exportEntry("get", 0x00, fnGet), + exportEntry("get_pending", 0x00, fnGetPending), + exportEntry("set_admin", 0x00, fnSetAdmin), + )) + + dataSection := section(0x0B, cat( + u(14), + dataSegment(offKeyAdmin, []byte("admin")), + dataSegment(offPfxParam, []byte("param:")), + dataSegment(offPfxProp, []byte("prop:")), + dataSegment(offInitedPfx, []byte("initialized: ")), + dataSegment(offProposedPfx, []byte("proposed: ")), + dataSegment(offApprovedPfx, []byte("approved: ")), + dataSegment(offRejectedPfx, []byte("rejected: ")), + dataSegment(offValuePfx, []byte("value: ")), + dataSegment(offNotSetPfx, []byte("not set: ")), + dataSegment(offPendingPfx, []byte("pending: ")), + dataSegment(offNoPendingPfx, []byte("no pending: ")), + dataSegment(offAdminPfx, []byte("admin: ")), + dataSegment(offUnauthPfx, []byte("unauthorized: ")), + dataSegment(offAlreadyPfx, []byte("already init")), + )) + + codeSection := section(0x0A, cat( + u(11), + bytesEqualBody(), + memcpyBody(), + logPrefixNameBody(), + buildKeyBody(), + initBody(), + proposeBody(), + approveBody(), + rejectBody(), + getBody(), + getPendingBody(), + setAdminBody(), + )) + + module := cat( + []byte{0x00, 0x61, 0x73, 0x6d}, + []byte{0x01, 0x00, 0x00, 0x00}, + typeSection, + importSection, + functionSection, + memorySection, + exportSection, + dataSection, + codeSection, + ) + + out := "contracts/governance/governance.wasm" + if err := os.WriteFile(out, module, 0644); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } + fmt.Printf("Written %s (%d bytes)\n", out, len(module)) +} diff --git a/contracts/governance/governance.wasm b/contracts/governance/governance.wasm new file mode 100644 index 0000000000000000000000000000000000000000..672a47928d48f7d832d7d8b5352eeda48d4a047b GIT binary patch literal 1390 zcmb_by-piJ5T4n+AG~uIKR}ATI!YdZBJvgpDN#t?08aSi*s|}e^MOPfJ3^wP;tA4* zp4xQD1CW*%pv(hgW_<>uI32dMx8Lly-+VK32ibIt0D!OKBR!kVaCW5p8ZZ##pCCav z0`MGA2cgc3Q|jmSVOI4Ir*#D&5hIb~Y&6O%Knb)YFs-vX2j%hf5o^JERQ4gz2|=V) z*!(ntiVwD;aXv1qGX=q*7}U^;CsjEqr#YB7oACZL2a2kEm><`WXify%-q9p4dV``5 z?Up3VdgDO>JMFVme*1Zf_SYd0tK5j7j<{{lLYf`z@H%XDPy=88kC zw+vlM7AADIQYEH&kpeUi*R}jXOg|CBO>!%*_}r_qYA;}Z$YrU0zmu{Snsq}W&dc*- zuaZ@YShEm_1zrMisZwpVu3nHGqvG#nH4kptt#L7Ti5nWze+1XMx>@j9SMNl<5uC0Z a@|y=K_kH=Myikw)Yc0dn?m?>XbEUtw3>2{d literal 0 HcmV?d00001 diff --git a/contracts/governance/governance_abi.json b/contracts/governance/governance_abi.json new file mode 100644 index 0000000..4eab9bc --- /dev/null +++ b/contracts/governance/governance_abi.json @@ -0,0 +1,55 @@ +{ + "contract": "governance", + "version": "1.0.0", + "description": "On-chain parameter governance. The deployer becomes the admin. Anyone can propose a parameter change; the admin approves or rejects it. Used to manage gas_price, messenger_entry_fee, relay_fee, etc.", + "methods": [ + { + "name": "init", + "description": "Initialize the contract, setting the caller as the admin. Must be called once after deployment.", + "args": [] + }, + { + "name": "propose", + "description": "Submit a proposal to change a named parameter. Anyone can propose. Logs 'proposed: ='.", + "args": [ + {"name": "key", "type": "string", "description": "Parameter name (max 64 chars)"}, + {"name": "value", "type": "string", "description": "Proposed new value (max 256 chars)"} + ] + }, + { + "name": "approve", + "description": "Admin approves the pending proposal for a key, committing it as the live value. Logs 'approved: '.", + "args": [ + {"name": "key", "type": "string", "description": "Parameter key to approve"} + ] + }, + { + "name": "reject", + "description": "Admin rejects and removes the pending proposal for a key. Logs 'rejected: '.", + "args": [ + {"name": "key", "type": "string", "description": "Parameter key to reject"} + ] + }, + { + "name": "get", + "description": "Read the current live value of a parameter. Logs 'value: ' or 'not set: '.", + "args": [ + {"name": "key", "type": "string", "description": "Parameter key"} + ] + }, + { + "name": "get_pending", + "description": "Read the pending proposed value. Logs 'pending: ' or 'no pending: '.", + "args": [ + {"name": "key", "type": "string", "description": "Parameter key"} + ] + }, + { + "name": "set_admin", + "description": "Transfer the admin role to a new address. Only the current admin may call this. Logs 'admin: '.", + "args": [ + {"name": "new_admin", "type": "string", "description": "New admin address (hex pubkey)"} + ] + } + ] +} diff --git a/contracts/hello_go/hello_go_abi.json b/contracts/hello_go/hello_go_abi.json new file mode 100644 index 0000000..6efecaf --- /dev/null +++ b/contracts/hello_go/hello_go_abi.json @@ -0,0 +1,38 @@ +{ + "contract": "hello_go", + "version": "1.0.0", + "description": "Example DChain contract written in Go (TinyGo SDK). Demonstrates counter, owner-gated reset, string args, and inter-contract calls.", + "build": "tinygo build -o hello_go.wasm -target wasip1 -no-debug .", + "methods": [ + { + "name": "increment", + "description": "Add 1 to the counter. Logs 'counter: N'.", + "args": [] + }, + { + "name": "get", + "description": "Log the current counter value without changing state.", + "args": [] + }, + { + "name": "reset", + "description": "Reset counter to 0. First caller becomes the owner; only owner can reset.", + "args": [] + }, + { + "name": "greet", + "description": "Log a greeting. Logs 'hello, ! block=N'.", + "args": [ + {"name": "name", "type": "string", "description": "Name to greet (defaults to 'world')"} + ] + }, + { + "name": "ping", + "description": "Call a method on another contract (inter-contract call demo).", + "args": [ + {"name": "contract_id", "type": "string", "description": "Target contract ID"}, + {"name": "method", "type": "string", "description": "Method to call on target"} + ] + } + ] +} diff --git a/contracts/hello_go/main.go b/contracts/hello_go/main.go new file mode 100644 index 0000000..e6f1e83 --- /dev/null +++ b/contracts/hello_go/main.go @@ -0,0 +1,104 @@ +// Package main is an example DChain smart contract written in Go. +// +// # Build +// +// tinygo build -o hello_go.wasm -target wasip1 -no-debug . +// +// # Deploy +// +// client deploy-contract --key /keys/node1.json \ +// --wasm hello_go.wasm --abi hello_go_abi.json \ +// --node http://localhost:8081 +// +// # Use +// +// client call-contract --key /keys/node1.json --contract $ID \ +// --method increment --gas 5000 --node http://localhost:8081 +// +// The contract implements a simple counter with owner-gated reset. +// It demonstrates every SDK primitive: arguments, state, caller, logging, +// and inter-contract calls. +package main + +import ( + "strconv" + + dc "go-blockchain/contracts/sdk" +) + +// increment adds 1 to the counter and logs the new value. +// +//export increment +func increment() { + v := dc.GetU64("counter") + v++ + dc.PutU64("counter", v) + dc.Log("counter: " + strconv.FormatUint(v, 10)) +} + +// get logs the current counter value without changing state. +// +//export get +func get() { + v := dc.GetU64("counter") + dc.Log("counter: " + strconv.FormatUint(v, 10)) +} + +// reset sets the counter to 0. On first call the caller becomes the owner. +// Subsequent calls are restricted to the owner. +// +//export reset +func reset() { + owner := dc.GetStateStr("owner") + caller := dc.Caller() + if owner == "" { + dc.SetStateStr("owner", caller) + dc.PutU64("counter", 0) + dc.Log("initialized — owner: " + caller[:min8(caller)]) + return + } + if caller != owner { + dc.Log("unauthorized: " + caller[:min8(caller)]) + return + } + dc.PutU64("counter", 0) + dc.Log("reset by owner") +} + +// greet logs a personalised greeting using the first call argument. +// +//export greet +func greet() { + name := dc.ArgStr(0, 64) + if name == "" { + name = "world" + } + dc.Log("hello, " + name + "! block=" + strconv.FormatUint(dc.BlockHeight(), 10)) +} + +// ping calls another contract's method (demonstrates inter-contract calls). +// Args: contract_id (string), method (string) +// +//export ping +func ping() { + target := dc.ArgStr(0, 64) + method := dc.ArgStr(1, 64) + if target == "" || method == "" { + dc.Log("ping: target and method required") + return + } + if dc.CallContract(target, method, "[]") { + dc.Log("ping: " + method + " ok") + } else { + dc.Log("ping: " + method + " failed") + } +} + +func min8(s string) int { + if len(s) < 8 { + return len(s) + } + return 8 +} + +func main() {} diff --git a/contracts/name_registry/gen/main.go b/contracts/name_registry/gen/main.go new file mode 100644 index 0000000..07dd189 --- /dev/null +++ b/contracts/name_registry/gen/main.go @@ -0,0 +1,458 @@ +// gen generates contracts/name_registry/name_registry.wasm +// Run from the repo root: go run ./contracts/name_registry/gen/ +// +// Contract methods: register, resolve, transfer, release +// Host imports from "env": get_arg_str, get_caller, get_state, set_state, log +// +// Every contract action emits a human-readable log entry visible in the +// block explorer, e.g.: +// "registered: alice" +// "name taken: alice" +// "not found: alice" +// "owner: " +// "transferred: alice" +// "unauthorized: alice" +// "released: alice" +package main + +import ( + "fmt" + "os" +) + +// ── LEB128 ─────────────────────────────────────────────────────────────────── + +func u(v uint64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + if v != 0 { + bt |= 0x80 + } + b = append(b, bt) + if v == 0 { + return b + } + } +} + +func s(v int64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + sign := (bt & 0x40) != 0 + if (v == 0 && !sign) || (v == -1 && sign) { + return append(b, bt) + } + b = append(b, bt|0x80) + } +} + +// ── Builders ────────────────────────────────────────────────────────────────── + +func cat(slices ...[]byte) []byte { + var out []byte + for _, sl := range slices { + out = append(out, sl...) + } + return out +} + +func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) } + +func section(id byte, content []byte) []byte { + return cat([]byte{id}, u(uint64(len(content))), content) +} + +func vec(items ...[]byte) []byte { + out := u(uint64(len(items))) + for _, it := range items { + out = append(out, it...) + } + return out +} + +func functype(params, results []byte) []byte { + return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results) +} + +func importFunc(mod, name string, typeIdx uint32) []byte { + return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx))) +} + +func exportEntry(name string, kind byte, idx uint32) []byte { + return cat(wstr(name), []byte{kind}, u(uint64(idx))) +} + +func dataSegment(offset int32, data []byte) []byte { + return cat( + []byte{0x00}, + []byte{0x41}, s(int64(offset)), []byte{0x0B}, + u(uint64(len(data))), data, + ) +} + +func funcBody(localDecls []byte, instrs ...[]byte) []byte { + inner := cat(localDecls) + for _, ins := range instrs { + inner = append(inner, ins...) + } + inner = append(inner, 0x0B) // end + return cat(u(uint64(len(inner))), inner) +} + +var noLocals = u(0) + +func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) } +func withLocals(decls ...[]byte) []byte { + return cat(u(uint64(len(decls))), cat(decls...)) +} + +// ── Instructions ────────────────────────────────────────────────────────────── + +const ( + tI32 byte = 0x7F + tI64 byte = 0x7E +) + +func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) } +func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) } +func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) } +func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) } +func block_() []byte { return []byte{0x02, 0x40} } +func loop_() []byte { return []byte{0x03, 0x40} } +func if_() []byte { return []byte{0x04, 0x40} } +func ifI32() []byte { return []byte{0x04, tI32} } // if that returns i32 +func else_() []byte { return []byte{0x05} } +func end_() []byte { return []byte{0x0B} } +func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) } +func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) } +func return_() []byte { return []byte{0x0F} } +func i32Eqz() []byte { return []byte{0x45} } +func i32Ne() []byte { return []byte{0x47} } +func i32GtU() []byte { return []byte{0x4B} } +func i32GeU() []byte { return []byte{0x4F} } +func i32Add() []byte { return []byte{0x6A} } +func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} } +func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} } + +// ── Memory layout ───────────────────────────────────────────────────────────── + +const ( + offArg0 = 0x000 // arg[0] name buffer (64 bytes) + offArg1 = 0x040 // arg[1] new_owner buffer (128 bytes) + offCaller = 0x0C0 // caller pubkey buffer (128 bytes) + offStateRead = 0x140 // existing owner buffer (128 bytes) + + // Verbose prefix strings — each ends with ": " for readable log messages. + offRegisteredPfx = 0x200 // "registered: " 12 bytes + offNameTakenPfx = 0x20C // "name taken: " 12 bytes + offNotFoundPfx = 0x218 // "not found: " 11 bytes + offOwnerPfx = 0x224 // "owner: " 7 bytes + offTransferredPfx = 0x22C // "transferred: " 13 bytes + offUnauthPfx = 0x23A // "unauthorized: " 14 bytes + offReleasedPfx = 0x249 // "released: " 10 bytes + + // Scratch buffer for building concatenated log messages. + offScratch = 0x300 // 256 bytes +) + +// Import function indices (order must match importSection below) +const ( + fnGetArgStr = 0 // get_arg_str(idx, dstPtr, dstLen i32) → i32 + fnGetCaller = 1 // get_caller(bufPtr, bufLen i32) → i32 + fnGetState = 2 // get_state(kP,kL,dP,dL i32) → i32 + fnSetState = 3 // set_state(kP,kL,vP,vL i32) + fnLog = 4 // log(msgPtr, msgLen i32) +) + +// Local function indices (imports first, then locals in declaration order) +const ( + fnBytesEqual = 5 // $bytes_equal(aPtr,bPtr,len i32) → i32 + fnMemcpy = 6 // $memcpy(dst,src,len i32) + fnLogPrefixName = 7 // $log_prefix_name(pfxPtr,pfxLen,sfxPtr,sfxLen i32) + fnRegister = 8 + fnResolve = 9 + fnTransfer = 10 + fnRelease = 11 +) + +// ── $bytes_equal helper ─────────────────────────────────────────────────────── +// (aPtr i32, bPtr i32, len i32) → i32 +// locals: i(3), same(4) +func bytesEqualBody() []byte { + return funcBody( + withLocals(localDecl(2, tI32)), // locals 3=i, 4=same + // same = 1; i = 0 + ic32(1), lset(4), + ic32(0), lset(3), + block_(), + loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), // i >= len → exit block + // load mem[aPtr+i] + lget(0), lget(3), i32Add(), i32Load8U(), + // load mem[bPtr+i] + lget(1), lget(3), i32Add(), i32Load8U(), + i32Ne(), if_(), + ic32(0), lset(4), + br_(2), // exit block + end_(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), // next iteration + end_(), + end_(), + lget(4), // return same + ) +} + +// ── $memcpy helper ──────────────────────────────────────────────────────────── +// (dst i32, src i32, len i32) — copies len bytes from src to dst. +// locals: i(3) +func memcpyBody() []byte { + return funcBody( + withLocals(localDecl(1, tI32)), // local 3 = i + ic32(0), lset(3), // i = 0 + block_(), + loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), // if i >= len: exit + // mem[dst+i] = mem[src+i] + lget(0), lget(3), i32Add(), // dst+i (store address) + lget(1), lget(3), i32Add(), i32Load8U(), // load mem[src+i] + i32Store8(), + lget(3), ic32(1), i32Add(), lset(3), // i++ + br_(0), + end_(), + end_(), + ) +} + +// ── $log_prefix_name helper ─────────────────────────────────────────────────── +// (prefixPtr i32, prefixLen i32, suffixPtr i32, suffixLen i32) +// Builds "prefix" in scratch buffer and logs it. +func logPrefixNameBody() []byte { + return funcBody( + noLocals, + // memcpy(offScratch, prefixPtr, prefixLen) + ic32(offScratch), lget(0), lget(1), call(fnMemcpy), + // memcpy(offScratch + prefixLen, suffixPtr, suffixLen) + ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy), + // log(offScratch, prefixLen + suffixLen) + ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog), + ) +} + +// ── isOwner: shared caller-vs-existing check ────────────────────────────────── +// Assumes: caller is at offCaller with len callerLenLocal, +// existing is at offStateRead with len existingLenLocal. +// Returns instructions that leave i32 (1=same, 0=not) on stack. +func isOwnerCheck(callerLenLocal, existingLenLocal uint32) []byte { + return cat( + // if callerLen != existingLen → push 0 + ifI32(), + ic32(0), + else_(), + // else call bytes_equal(offCaller, offStateRead, callerLen) + ic32(offCaller), ic32(offStateRead), lget(callerLenLocal), + call(fnBytesEqual), + end_(), + ) +} + +func main() { + // ── Type section ────────────────────────────────────────────────────────── + // Type 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal + // Type 1: (i32,i32)→(i32) get_caller + // Type 2: (i32,i32,i32,i32)→(i32) get_state + // Type 3: (i32,i32,i32,i32)→() set_state, log_prefix_name + // Type 4: (i32,i32)→() log + // Type 5: ()→() register, resolve, transfer, release + // Type 6: (i32,i32,i32)→() memcpy + typeSection := section(0x01, vec( + functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0 + functype([]byte{tI32, tI32}, []byte{tI32}), // 1 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3 + functype([]byte{tI32, tI32}, []byte{}), // 4 + functype([]byte{}, []byte{}), // 5 + functype([]byte{tI32, tI32, tI32}, []byte{}), // 6 + )) + + // ── Import section ──────────────────────────────────────────────────────── + importSection := section(0x02, vec( + importFunc("env", "get_arg_str", 0), // fnGetArgStr=0 type 0 + importFunc("env", "get_caller", 1), // fnGetCaller=1 type 1 + importFunc("env", "get_state", 2), // fnGetState=2 type 2 + importFunc("env", "set_state", 3), // fnSetState=3 type 3 + importFunc("env", "log", 4), // fnLog=4 type 4 + )) + + // ── Function section: 7 local functions ────────────────────────────────── + functionSection := section(0x03, vec( + u(0), // bytes_equal → type 0 + u(6), // memcpy → type 6 + u(3), // log_prefix_name → type 3 + u(5), // register → type 5 + u(5), // resolve → type 5 + u(5), // transfer → type 5 + u(5), // release → type 5 + )) + + // ── Memory section ──────────────────────────────────────────────────────── + memorySection := section(0x05, vec(cat([]byte{0x00}, u(1)))) + + // ── Export section ──────────────────────────────────────────────────────── + exportSection := section(0x07, vec( + exportEntry("memory", 0x02, 0), + exportEntry("register", 0x00, fnRegister), + exportEntry("resolve", 0x00, fnResolve), + exportEntry("transfer", 0x00, fnTransfer), + exportEntry("release", 0x00, fnRelease), + )) + + // ── Data section ────────────────────────────────────────────────────────── + dataSection := section(0x0B, cat( + u(7), + dataSegment(offRegisteredPfx, []byte("registered: ")), + dataSegment(offNameTakenPfx, []byte("name taken: ")), + dataSegment(offNotFoundPfx, []byte("not found: ")), + dataSegment(offOwnerPfx, []byte("owner: ")), + dataSegment(offTransferredPfx, []byte("transferred: ")), + dataSegment(offUnauthPfx, []byte("unauthorized: ")), + dataSegment(offReleasedPfx, []byte("released: ")), + )) + + // ── Code section ───────────────────────────────────────────────────────── + + // register(): locals nameLen(0), callerLen(1), existingLen(2) + registerBody := funcBody( + withLocals(localDecl(3, tI32)), + // nameLen = get_arg_str(0, offArg0, 64) + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + // if nameLen == 0: return + lget(0), i32Eqz(), if_(), return_(), end_(), + // existingLen = get_state(offArg0, nameLen, offStateRead, 128) + ic32(offArg0), lget(0), ic32(offStateRead), ic32(128), call(fnGetState), lset(2), + // if existingLen > 0: log("name taken: "); return + lget(2), ic32(0), i32GtU(), if_(), + ic32(offNameTakenPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + // callerLen = get_caller(offCaller, 128) + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + // set_state(offArg0, nameLen, offCaller, callerLen) + ic32(offArg0), lget(0), ic32(offCaller), lget(1), call(fnSetState), + // log("registered: ") + ic32(offRegisteredPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) + + // resolve(): locals nameLen(0), ownerLen(1) + resolveBody := funcBody( + withLocals(localDecl(2, tI32)), + // nameLen = get_arg_str(0, offArg0, 64) + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + // ownerLen = get_state(offArg0, nameLen, offStateRead, 128) + ic32(offArg0), lget(0), ic32(offStateRead), ic32(128), call(fnGetState), lset(1), + // if ownerLen == 0: log("not found: "); return + lget(1), i32Eqz(), if_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + // log("owner: ") + // The pubkey stored in state is the raw caller string (hex-encoded), + // so the log will display the human-readable address. + ic32(offOwnerPfx), ic32(7), ic32(offStateRead), lget(1), call(fnLogPrefixName), + ) + + // transfer(): locals nameLen(0), newOwnerLen(1), callerLen(2), existingLen(3) + transferBody := funcBody( + withLocals(localDecl(4, tI32)), + // nameLen = get_arg_str(0, offArg0, 64) + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + // newOwnerLen = get_arg_str(1, offArg1, 128) + ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1), + lget(1), i32Eqz(), if_(), return_(), end_(), + // existingLen = get_state(offArg0, nameLen, offStateRead, 128) + ic32(offArg0), lget(0), ic32(offStateRead), ic32(128), call(fnGetState), lset(3), + // if existingLen == 0: not registered → anyone can claim + lget(3), i32Eqz(), if_(), + ic32(offArg0), lget(0), ic32(offArg1), lget(1), call(fnSetState), + ic32(offTransferredPfx), ic32(13), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + // callerLen = get_caller(offCaller, 128) + ic32(offCaller), ic32(128), call(fnGetCaller), lset(2), + // isOwner = (callerLen == existingLen) ? bytes_equal(...) : 0 + lget(2), lget(3), i32Ne(), + isOwnerCheck(2, 3), + // if !isOwner: log("unauthorized: "); return + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + // Authorized + ic32(offArg0), lget(0), ic32(offArg1), lget(1), call(fnSetState), + ic32(offTransferredPfx), ic32(13), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) + + // release(): locals nameLen(0), callerLen(1), existingLen(2) + releaseBody := funcBody( + withLocals(localDecl(3, tI32)), + // nameLen = get_arg_str(0, offArg0, 64) + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + // existingLen = get_state(offArg0, nameLen, offStateRead, 128) + ic32(offArg0), lget(0), ic32(offStateRead), ic32(128), call(fnGetState), lset(2), + // if existingLen == 0: log("not found: "); return + lget(2), i32Eqz(), if_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + // callerLen = get_caller(offCaller, 128) + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + // isOwner check + lget(1), lget(2), i32Ne(), + isOwnerCheck(1, 2), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + // Store empty value → effectively releases the name + ic32(offArg0), lget(0), ic32(offArg0), ic32(0), call(fnSetState), + ic32(offReleasedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) + + codeSection := section(0x0A, cat( + u(7), + bytesEqualBody(), + memcpyBody(), + logPrefixNameBody(), + registerBody, + resolveBody, + transferBody, + releaseBody, + )) + + // ── Assemble module ─────────────────────────────────────────────────────── + module := cat( + []byte{0x00, 0x61, 0x73, 0x6d}, // \0asm + []byte{0x01, 0x00, 0x00, 0x00}, // version 1 + typeSection, + importSection, + functionSection, + memorySection, + exportSection, + dataSection, + codeSection, + ) + + out := "contracts/name_registry/name_registry.wasm" + if err := os.WriteFile(out, module, 0644); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } + fmt.Printf("Written %s (%d bytes)\n", out, len(module)) +} diff --git a/contracts/name_registry/name_registry.wasm b/contracts/name_registry/name_registry.wasm new file mode 100644 index 0000000000000000000000000000000000000000..d7db00dc5adcef4f9fcdcf2994a270ac18d1f58b GIT binary patch literal 908 zcma)3!EVz)5S`gwXH!p7{0Zwbf)l46P!S&g*s6r`#B_^_MC{xxFP~r#fD22JGHY`XJ35=?`OH zqUt>6#zU^vOwXKl>fIC_IK6xZ;BeSa&5Se5M3K`Tm${yB>(0<~#O_e(uI_{>eicy8 zUPsK0*+>q!bjs<6GYe&g-lJwhUR%f@2M4ds>3uCbZz`ZZZgXm zTa;VOamYp1&^*{(2E7ua|0d=dR^v(gUa+UVG2XH=vswC%Xby28H!I?h4-9|wtDP3w z-hx=y{91ncsByOC^r;9WVum$UmXK858GpP_KS)EH{~FsPcl)97d#T^F@o(BZLUwR( Jd^)s5KLORVurL4s literal 0 HcmV?d00001 diff --git a/contracts/name_registry/name_registry.wat b/contracts/name_registry/name_registry.wat new file mode 100644 index 0000000..00e6f0e --- /dev/null +++ b/contracts/name_registry/name_registry.wat @@ -0,0 +1,301 @@ +(module + ;; Name Registry smart contract + ;; + ;; Maps human-readable names → owner public keys on-chain. + ;; Each name can only be registered once; only the current owner can + ;; transfer or release it. + ;; + ;; Methods (all void, no WASM return values): + ;; register(name string) — claim a name for the caller + ;; resolve(name string) — log the current owner pubkey + ;; transfer(name string, new_owner string) — give name to another pubkey + ;; release(name string) — delete name registration + ;; + ;; State keys: the raw name bytes → owner pubkey bytes. + ;; All args come in via env.get_arg_str / env.get_arg_u64. + + ;; ── imports ────────────────────────────────────────────────────────────── + (import "env" "get_arg_str" + (func $get_arg_str (param i32 i32 i32) (result i32))) + (import "env" "get_caller" + (func $get_caller (param i32 i32) (result i32))) + (import "env" "get_state" + (func $get_state (param i32 i32 i32 i32) (result i32))) + (import "env" "set_state" + (func $set_state (param i32 i32 i32 i32))) + (import "env" "log" + (func $log (param i32 i32))) + + ;; ── memory ─────────────────────────────────────────────────────────────── + ;; Offset Size Purpose + ;; 0x000 64 arg[0] buffer — name (max 64 bytes) + ;; 0x040 128 arg[1] buffer — new_owner pubkey (max 128 bytes) + ;; 0x0C0 128 caller pubkey buffer + ;; 0x140 128 state-read buffer (existing owner) + ;; 0x200 ~128 verbose log prefix strings + ;; 0x300 256 scratch buffer — build "prefix: name" log messages + (memory (export "memory") 1) + + ;; ── verbose log prefix strings ─────────────────────────────────────────── + ;; Each entry is a human-readable prefix ending with ": " so that the + ;; log message becomes "prefix: " — readable in the explorer. + ;; + ;; "registered: " 12 bytes @ 0x200 + ;; "name taken: " 12 bytes @ 0x20C + ;; "not found: " 11 bytes @ 0x218 + ;; "owner: " 7 bytes @ 0x224 + ;; "transferred: " 13 bytes @ 0x22C + ;; "unauthorized: " 14 bytes @ 0x23A + ;; "released: " 10 bytes @ 0x249 + (data (i32.const 0x200) "registered: ") + (data (i32.const 0x20C) "name taken: ") + (data (i32.const 0x218) "not found: ") + (data (i32.const 0x224) "owner: ") + (data (i32.const 0x22C) "transferred: ") + (data (i32.const 0x23A) "unauthorized: ") + (data (i32.const 0x249) "released: ") + + ;; ── helpers ─────────────────────────────────────────────────────────────── + + ;; $memcpy: copy len bytes from src to dst + (func $memcpy (param $dst i32) (param $src i32) (param $len i32) + (local $i i32) + (local.set $i (i32.const 0)) + (block $break + (loop $loop + (br_if $break (i32.ge_u (local.get $i) (local.get $len))) + (i32.store8 + (i32.add (local.get $dst) (local.get $i)) + (i32.load8_u (i32.add (local.get $src) (local.get $i)))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $loop) + ) + ) + ) + + ;; $log_prefix_name: build "" in scratch buf 0x300 and log it. + ;; prefixPtr / prefixLen — the prefix string (e.g. "registered: ", 12 bytes) + ;; suffixPtr / suffixLen — the name / pubkey to append + (func $log_prefix_name + (param $prefixPtr i32) (param $prefixLen i32) + (param $suffixPtr i32) (param $suffixLen i32) + ;; copy prefix → scratch[0] + (call $memcpy (i32.const 0x300) (local.get $prefixPtr) (local.get $prefixLen)) + ;; copy suffix → scratch[prefixLen] + (call $memcpy + (i32.add (i32.const 0x300) (local.get $prefixLen)) + (local.get $suffixPtr) + (local.get $suffixLen)) + ;; log scratch[0 .. prefixLen+suffixLen) + (call $log + (i32.const 0x300) + (i32.add (local.get $prefixLen) (local.get $suffixLen))) + ) + + ;; $bytes_equal: compare mem[aPtr..aPtr+len) with mem[bPtr..bPtr+len) + ;; Params: aPtr(0) bPtr(1) len(2) + ;; Result: i32 1 = equal, 0 = not equal + (func $bytes_equal (param i32 i32 i32) (result i32) + (local $i i32) + (local $same i32) + (local.set $same (i32.const 1)) + (local.set $i (i32.const 0)) + (block $break + (loop $loop + (br_if $break (i32.ge_u (local.get $i) (local.get 2))) + (if (i32.ne + (i32.load8_u (i32.add (local.get 0) (local.get $i))) + (i32.load8_u (i32.add (local.get 1) (local.get $i)))) + (then (local.set $same (i32.const 0)) (br $break)) + ) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $loop) + ) + ) + (local.get $same) + ) + + ;; ── register(name) ──────────────────────────────────────────────────────── + ;; Claims `name` for the caller. + ;; Logs "registered: " on success, "name taken: " on conflict. + (func (export "register") + (local $nameLen i32) + (local $callerLen i32) + (local $existingLen i32) + + ;; Read name into 0x000, max 64 bytes + (local.set $nameLen + (call $get_arg_str (i32.const 0) (i32.const 0x000) (i32.const 64))) + (if (i32.eqz (local.get $nameLen)) (then return)) + + ;; Check if name is already taken + (local.set $existingLen + (call $get_state + (i32.const 0x000) (local.get $nameLen) + (i32.const 0x140) (i32.const 128))) + (if (i32.gt_u (local.get $existingLen) (i32.const 0)) + (then + (call $log_prefix_name + (i32.const 0x20C) (i32.const 12) ;; "name taken: " + (i32.const 0x000) (local.get $nameLen)) + return + ) + ) + + ;; Store: state[name] = caller_pubkey + (local.set $callerLen + (call $get_caller (i32.const 0x0C0) (i32.const 128))) + (call $set_state + (i32.const 0x000) (local.get $nameLen) + (i32.const 0x0C0) (local.get $callerLen)) + + (call $log_prefix_name + (i32.const 0x200) (i32.const 12) ;; "registered: " + (i32.const 0x000) (local.get $nameLen)) + ) + + ;; ── resolve(name) ───────────────────────────────────────────────────────── + ;; Logs "owner: " for the registered name, or "not found: ". + (func (export "resolve") + (local $nameLen i32) + (local $ownerLen i32) + + (local.set $nameLen + (call $get_arg_str (i32.const 0) (i32.const 0x000) (i32.const 64))) + (if (i32.eqz (local.get $nameLen)) (then return)) + + (local.set $ownerLen + (call $get_state + (i32.const 0x000) (local.get $nameLen) + (i32.const 0x140) (i32.const 128))) + (if (i32.eqz (local.get $ownerLen)) + (then + (call $log_prefix_name + (i32.const 0x218) (i32.const 11) ;; "not found: " + (i32.const 0x000) (local.get $nameLen)) + return + ) + ) + ;; Log "owner: " + ;; The pubkey stored in state is the raw caller pubkey bytes that the + ;; host wrote via get_caller — these are the hex-encoded public key + ;; string bytes, so the log will show the readable hex address. + (call $log_prefix_name + (i32.const 0x224) (i32.const 7) ;; "owner: " + (i32.const 0x140) (local.get $ownerLen)) + ) + + ;; ── transfer(name, new_owner) ───────────────────────────────────────────── + ;; Transfers ownership of `name` to `new_owner`. + ;; Only the current owner may call this (or anyone if name is unregistered). + ;; Logs "transferred: " on success, "unauthorized: " otherwise. + (func (export "transfer") + (local $nameLen i32) + (local $newOwnerLen i32) + (local $callerLen i32) + (local $existingLen i32) + + (local.set $nameLen + (call $get_arg_str (i32.const 0) (i32.const 0x000) (i32.const 64))) + (if (i32.eqz (local.get $nameLen)) (then return)) + + (local.set $newOwnerLen + (call $get_arg_str (i32.const 1) (i32.const 0x040) (i32.const 128))) + (if (i32.eqz (local.get $newOwnerLen)) (then return)) + + ;; Read existing owner into 0x140 + (local.set $existingLen + (call $get_state + (i32.const 0x000) (local.get $nameLen) + (i32.const 0x140) (i32.const 128))) + + ;; If not registered, anyone can claim → register directly for new_owner + (if (i32.eqz (local.get $existingLen)) + (then + (call $set_state + (i32.const 0x000) (local.get $nameLen) + (i32.const 0x040) (local.get $newOwnerLen)) + (call $log_prefix_name + (i32.const 0x22C) (i32.const 13) ;; "transferred: " + (i32.const 0x000) (local.get $nameLen)) + return + ) + ) + + ;; Verify caller == existing owner + (local.set $callerLen + (call $get_caller (i32.const 0x0C0) (i32.const 128))) + (if (i32.eqz + (call $bytes_equal + (i32.const 0x0C0) (i32.const 0x140) + (if (result i32) (i32.ne (local.get $callerLen) (local.get $existingLen)) + (then (i32.const 0)) ;; length mismatch → not equal + (else (local.get $callerLen))))) + (then + (call $log_prefix_name + (i32.const 0x23A) (i32.const 14) ;; "unauthorized: " + (i32.const 0x000) (local.get $nameLen)) + return + ) + ) + + ;; Authorized — update owner + (call $set_state + (i32.const 0x000) (local.get $nameLen) + (i32.const 0x040) (local.get $newOwnerLen)) + (call $log_prefix_name + (i32.const 0x22C) (i32.const 13) ;; "transferred: " + (i32.const 0x000) (local.get $nameLen)) + ) + + ;; ── release(name) ───────────────────────────────────────────────────────── + ;; Removes a name registration. Only the current owner may call this. + ;; Logs "released: " on success. + (func (export "release") + (local $nameLen i32) + (local $callerLen i32) + (local $existingLen i32) + + (local.set $nameLen + (call $get_arg_str (i32.const 0) (i32.const 0x000) (i32.const 64))) + (if (i32.eqz (local.get $nameLen)) (then return)) + + (local.set $existingLen + (call $get_state + (i32.const 0x000) (local.get $nameLen) + (i32.const 0x140) (i32.const 128))) + (if (i32.eqz (local.get $existingLen)) + (then + (call $log_prefix_name + (i32.const 0x218) (i32.const 11) ;; "not found: " + (i32.const 0x000) (local.get $nameLen)) + return + ) + ) + + ;; Verify caller == owner + (local.set $callerLen + (call $get_caller (i32.const 0x0C0) (i32.const 128))) + (if (i32.eqz + (call $bytes_equal + (i32.const 0x0C0) (i32.const 0x140) + (if (result i32) (i32.ne (local.get $callerLen) (local.get $existingLen)) + (then (i32.const 0)) + (else (local.get $callerLen))))) + (then + (call $log_prefix_name + (i32.const 0x23A) (i32.const 14) ;; "unauthorized: " + (i32.const 0x000) (local.get $nameLen)) + return + ) + ) + + ;; Store empty bytes → effectively deletes the record + (call $set_state + (i32.const 0x000) (local.get $nameLen) + (i32.const 0x000) (i32.const 0)) + (call $log_prefix_name + (i32.const 0x249) (i32.const 10) ;; "released: " + (i32.const 0x000) (local.get $nameLen)) + ) +) diff --git a/contracts/name_registry/name_registry_abi.json b/contracts/name_registry/name_registry_abi.json new file mode 100644 index 0000000..ac826b8 --- /dev/null +++ b/contracts/name_registry/name_registry_abi.json @@ -0,0 +1,6 @@ +{"methods":[ + {"name":"register", "args":[{"name":"name","type":"string"}]}, + {"name":"resolve", "args":[{"name":"name","type":"string"}]}, + {"name":"transfer", "args":[{"name":"name","type":"string"},{"name":"new_owner","type":"string"}]}, + {"name":"release", "args":[{"name":"name","type":"string"}]} +]} diff --git a/contracts/sdk/dchain.go b/contracts/sdk/dchain.go new file mode 100644 index 0000000..7cfb92a --- /dev/null +++ b/contracts/sdk/dchain.go @@ -0,0 +1,210 @@ +//go:build tinygo + +// Package dchain is the DChain smart contract SDK for TinyGo. +// +// # Build a contract +// +// tinygo build -o mycontract.wasm -target wasip1 -no-debug ./mycontract +// +// # Deploy +// +// client deploy-contract --key key.json \ +// --wasm mycontract.wasm --abi mycontract_abi.json \ +// --node http://localhost:8081 +// +// Each exported Go function becomes a callable contract method. +// All inputs come through Arg*/GetState; all outputs go through Log/SetState. +package dchain + +import "unsafe" + +// ── Argument accessors ──────────────────────────────────────────────────────── + +//go:wasmimport env get_arg_str +func hostGetArgStr(idx uint32, ptr uintptr, maxLen uint32) uint32 + +//go:wasmimport env get_arg_u64 +func hostGetArgU64(idx uint32) uint64 + +// ArgStr returns the idx-th call argument as a string (max maxLen bytes). +// Returns "" if the index is out of range. +func ArgStr(idx int, maxLen int) string { + buf := make([]byte, maxLen) + n := hostGetArgStr(uint32(idx), uintptr(unsafe.Pointer(&buf[0])), uint32(maxLen)) + if n == 0 { + return "" + } + return string(buf[:n]) +} + +// ArgU64 returns the idx-th call argument as a uint64. Returns 0 if out of range. +func ArgU64(idx int) uint64 { + return hostGetArgU64(uint32(idx)) +} + +// ── State ───────────────────────────────────────────────────────────────────── + +//go:wasmimport env get_state +func hostGetState(kPtr uintptr, kLen uint32, dstPtr uintptr, dstLen uint32) uint32 + +//go:wasmimport env set_state +func hostSetState(kPtr uintptr, kLen uint32, vPtr uintptr, vLen uint32) + +//go:wasmimport env get_u64 +func hostGetU64(kPtr uintptr, kLen uint32) uint64 + +//go:wasmimport env put_u64 +func hostPutU64(kPtr uintptr, kLen uint32, val uint64) + +// GetState reads a value from contract state by key. +// Returns nil if the key does not exist. +func GetState(key string) []byte { + k := []byte(key) + buf := make([]byte, 1024) + n := hostGetState( + uintptr(unsafe.Pointer(&k[0])), uint32(len(k)), + uintptr(unsafe.Pointer(&buf[0])), uint32(len(buf)), + ) + if n == 0 { + return nil + } + return buf[:n] +} + +// GetStateStr reads a contract state value as a string. +func GetStateStr(key string) string { + v := GetState(key) + if v == nil { + return "" + } + return string(v) +} + +// SetState writes a value to contract state. +// Passing an empty slice clears the key. +func SetState(key string, value []byte) { + k := []byte(key) + if len(value) == 0 { + // vPtr=0, vLen=0 → clear key + hostSetState(uintptr(unsafe.Pointer(&k[0])), uint32(len(k)), 0, 0) + return + } + hostSetState( + uintptr(unsafe.Pointer(&k[0])), uint32(len(k)), + uintptr(unsafe.Pointer(&value[0])), uint32(len(value)), + ) +} + +// SetStateStr writes a string value to contract state. +func SetStateStr(key, value string) { + SetState(key, []byte(value)) +} + +// GetU64 reads a uint64 stored by PutU64 from contract state. +func GetU64(key string) uint64 { + k := []byte(key) + return hostGetU64(uintptr(unsafe.Pointer(&k[0])), uint32(len(k))) +} + +// PutU64 stores a uint64 in contract state as 8-byte big-endian. +func PutU64(key string, val uint64) { + k := []byte(key) + hostPutU64(uintptr(unsafe.Pointer(&k[0])), uint32(len(k)), val) +} + +// ── Caller & chain ──────────────────────────────────────────────────────────── + +//go:wasmimport env get_caller +func hostGetCaller(bufPtr uintptr, bufLen uint32) uint32 + +//go:wasmimport env get_block_height +func hostGetBlockHeight() uint64 + +//go:wasmimport env get_contract_treasury +func hostGetContractTreasury(bufPtr uintptr, bufLen uint32) uint32 + +// Caller returns the hex pubkey of the transaction sender (or parent contract ID). +func Caller() string { + buf := make([]byte, 128) + n := hostGetCaller(uintptr(unsafe.Pointer(&buf[0])), uint32(len(buf))) + return string(buf[:n]) +} + +// BlockHeight returns the height of the block currently being processed. +func BlockHeight() uint64 { + return hostGetBlockHeight() +} + +// Treasury returns the contract's ownerless escrow address. +// Derived as hex(sha256(contractID+":treasury")); no private key exists. +// Only this contract can spend from it via Transfer. +func Treasury() string { + buf := make([]byte, 64) + n := hostGetContractTreasury(uintptr(unsafe.Pointer(&buf[0])), uint32(len(buf))) + return string(buf[:n]) +} + +// ── Token operations ────────────────────────────────────────────────────────── + +//go:wasmimport env get_balance +func hostGetBalance(pubPtr uintptr, pubLen uint32) int64 + +//go:wasmimport env transfer +func hostTransfer(fromPtr uintptr, fromLen uint32, toPtr uintptr, toLen uint32, amount uint64) uint32 + +// Balance returns the token balance of a hex pubkey address in µT. +func Balance(pubKey string) uint64 { + p := []byte(pubKey) + return uint64(hostGetBalance(uintptr(unsafe.Pointer(&p[0])), uint32(len(p)))) +} + +// Transfer sends amount µT from one address to another. +// Returns true on success, false if from has insufficient balance. +func Transfer(from, to string, amount uint64) bool { + f := []byte(from) + t := []byte(to) + return hostTransfer( + uintptr(unsafe.Pointer(&f[0])), uint32(len(f)), + uintptr(unsafe.Pointer(&t[0])), uint32(len(t)), + amount, + ) == 0 +} + +// ── Inter-contract calls ────────────────────────────────────────────────────── + +//go:wasmimport env call_contract +func hostCallContract(cidPtr uintptr, cidLen uint32, mthPtr uintptr, mthLen uint32, argPtr uintptr, argLen uint32) uint32 + +// CallContract executes a method on another deployed contract. +// argsJSON must be a JSON array, e.g. `["alice", "100"]`. +// Caller of the sub-contract is set to this contract's ID. +// Gas is shared — sub-call consumes from the parent's gas budget. +// Returns true on success. +func CallContract(contractID, method, argsJSON string) bool { + cid := []byte(contractID) + mth := []byte(method) + if argsJSON == "" { + argsJSON = "[]" + } + arg := []byte(argsJSON) + return hostCallContract( + uintptr(unsafe.Pointer(&cid[0])), uint32(len(cid)), + uintptr(unsafe.Pointer(&mth[0])), uint32(len(mth)), + uintptr(unsafe.Pointer(&arg[0])), uint32(len(arg)), + ) == 0 +} + +// ── Logging ─────────────────────────────────────────────────────────────────── + +//go:wasmimport env log +func hostLog(msgPtr uintptr, msgLen uint32) + +// Log writes a message to the contract log. +// Logs are visible in the block explorer at /contract?id= → Logs tab. +func Log(msg string) { + b := []byte(msg) + if len(b) == 0 { + return + } + hostLog(uintptr(unsafe.Pointer(&b[0])), uint32(len(b))) +} diff --git a/contracts/sdk/dchain_stub.go b/contracts/sdk/dchain_stub.go new file mode 100644 index 0000000..a1315b1 --- /dev/null +++ b/contracts/sdk/dchain_stub.go @@ -0,0 +1,53 @@ +//go:build !tinygo + +// Package dchain provides stub implementations for non-TinyGo builds. +// These allow go build / IDEs to compile contract code without TinyGo. +// The stubs panic at runtime — they are never executed in production. +package dchain + +// ArgStr returns the idx-th call argument as a string. +func ArgStr(idx int, maxLen int) string { panic("dchain: ArgStr requires TinyGo (tinygo build -target wasip1)") } + +// ArgU64 returns the idx-th call argument as a uint64. +func ArgU64(idx int) uint64 { panic("dchain: ArgU64 requires TinyGo") } + +// GetState reads a value from contract state. +func GetState(key string) []byte { panic("dchain: GetState requires TinyGo") } + +// GetStateStr reads a contract state value as a string. +func GetStateStr(key string) string { panic("dchain: GetStateStr requires TinyGo") } + +// SetState writes a value to contract state. +func SetState(key string, value []byte) { panic("dchain: SetState requires TinyGo") } + +// SetStateStr writes a string value to contract state. +func SetStateStr(key, value string) { panic("dchain: SetStateStr requires TinyGo") } + +// GetU64 reads a uint64 from contract state. +func GetU64(key string) uint64 { panic("dchain: GetU64 requires TinyGo") } + +// PutU64 stores a uint64 in contract state. +func PutU64(key string, val uint64) { panic("dchain: PutU64 requires TinyGo") } + +// Caller returns the hex pubkey of the transaction sender. +func Caller() string { panic("dchain: Caller requires TinyGo") } + +// BlockHeight returns the current block height. +func BlockHeight() uint64 { panic("dchain: BlockHeight requires TinyGo") } + +// Treasury returns the contract's ownerless treasury address. +func Treasury() string { panic("dchain: Treasury requires TinyGo") } + +// Balance returns the token balance of a hex pubkey in µT. +func Balance(pubKey string) uint64 { panic("dchain: Balance requires TinyGo") } + +// Transfer sends amount µT from one address to another. +func Transfer(from, to string, amount uint64) bool { panic("dchain: Transfer requires TinyGo") } + +// CallContract executes a method on another deployed contract. +func CallContract(contractID, method, argsJSON string) bool { + panic("dchain: CallContract requires TinyGo") +} + +// Log writes a message to the contract log. +func Log(msg string) { panic("dchain: Log requires TinyGo") } diff --git a/contracts/username_registry/gen/main.go b/contracts/username_registry/gen/main.go new file mode 100644 index 0000000..28a07ef --- /dev/null +++ b/contracts/username_registry/gen/main.go @@ -0,0 +1,678 @@ +// gen generates contracts/username_registry/username_registry.wasm +// Run from repo root: go run ./contracts/username_registry/gen/ +// +// Methods: register, resolve, lookup, transfer, release, fee +// +// State layout: +// "name:" → owner address bytes +// "addr:
" → name bytes +// +// Fee schedule (µT): +// len=1 → 10_000_000 len=2 → 1_000_000 len=3 → 100_000 +// len=4 → 10_000 len=5 → 1_000 len≥6 → 100 +// +// Fees are transferred from caller to the contract treasury address +// (sha256(contractID+":treasury") — ownerless, only the contract can spend). +package main + +import ( + "fmt" + "os" +) + +// ── LEB128 ─────────────────────────────────────────────────────────────────── + +func u(v uint64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + if v != 0 { + bt |= 0x80 + } + b = append(b, bt) + if v == 0 { + return b + } + } +} + +func s(v int64) []byte { + var b []byte + for { + bt := byte(v & 0x7f) + v >>= 7 + sign := (bt & 0x40) != 0 + if (v == 0 && !sign) || (v == -1 && sign) { + return append(b, bt) + } + b = append(b, bt|0x80) + } +} + +// ── Builders ────────────────────────────────────────────────────────────────── + +func cat(slices ...[]byte) []byte { + var out []byte + for _, sl := range slices { + out = append(out, sl...) + } + return out +} + +func wstr(str string) []byte { return cat(u(uint64(len(str))), []byte(str)) } + +func section(id byte, content []byte) []byte { + return cat([]byte{id}, u(uint64(len(content))), content) +} + +func vec(items ...[]byte) []byte { + out := u(uint64(len(items))) + for _, it := range items { + out = append(out, it...) + } + return out +} + +func functype(params, results []byte) []byte { + return cat([]byte{0x60}, u(uint64(len(params))), params, u(uint64(len(results))), results) +} + +func importFunc(mod, name string, typeIdx uint32) []byte { + return cat(wstr(mod), wstr(name), []byte{0x00}, u(uint64(typeIdx))) +} + +func exportEntry(name string, kind byte, idx uint32) []byte { + return cat(wstr(name), []byte{kind}, u(uint64(idx))) +} + +func dataSegment(offset int32, data []byte) []byte { + return cat( + []byte{0x00}, + []byte{0x41}, s(int64(offset)), []byte{0x0B}, + u(uint64(len(data))), data, + ) +} + +func funcBody(localDecls []byte, instrs ...[]byte) []byte { + inner := cat(localDecls) + for _, ins := range instrs { + inner = append(inner, ins...) + } + inner = append(inner, 0x0B) // end + return cat(u(uint64(len(inner))), inner) +} + +var noLocals = u(0) + +func localDecl(n uint32, typ byte) []byte { return cat(u(uint64(n)), []byte{typ}) } +func withLocals(decls ...[]byte) []byte { + return cat(u(uint64(len(decls))), cat(decls...)) +} + +// ── Instructions ────────────────────────────────────────────────────────────── + +const ( + tI32 byte = 0x7F + tI64 byte = 0x7E +) + +func call(fn uint32) []byte { return cat([]byte{0x10}, u(uint64(fn))) } +func lget(i uint32) []byte { return cat([]byte{0x20}, u(uint64(i))) } +func lset(i uint32) []byte { return cat([]byte{0x21}, u(uint64(i))) } +func ic32(v int32) []byte { return cat([]byte{0x41}, s(int64(v))) } +func ic64(v int64) []byte { return cat([]byte{0x42}, s(v)) } +func block_() []byte { return []byte{0x02, 0x40} } +func loop_() []byte { return []byte{0x03, 0x40} } +func if_() []byte { return []byte{0x04, 0x40} } +func ifI32() []byte { return []byte{0x04, tI32} } +func else_() []byte { return []byte{0x05} } +func end_() []byte { return []byte{0x0B} } +func br_(lbl uint32) []byte { return cat([]byte{0x0C}, u(uint64(lbl))) } +func brIf_(lbl uint32) []byte { return cat([]byte{0x0D}, u(uint64(lbl))) } +func return_() []byte { return []byte{0x0F} } +func drop() []byte { return []byte{0x1A} } +func i32Eqz() []byte { return []byte{0x45} } +func i32Ne() []byte { return []byte{0x47} } +func i32GtU() []byte { return []byte{0x4B} } +func i32GeU() []byte { return []byte{0x4F} } +func i32LeU() []byte { return []byte{0x4D} } +func i32Add() []byte { return []byte{0x6A} } +func i64LtU() []byte { return []byte{0x54} } +func i64Eqz() []byte { return []byte{0x50} } +func i32Load8U() []byte { return []byte{0x2D, 0x00, 0x00} } +func i32Store8() []byte { return []byte{0x3A, 0x00, 0x00} } + +// ── Memory layout ───────────────────────────────────────────────────────────── +// +// 0x000 64 arg[0] name buffer +// 0x040 128 arg[1] new_owner buffer +// 0x0C0 128 caller buffer +// 0x140 64 contract treasury buffer +// 0x180 128 state-read result buffer +// +// Constant string data (read-only, written by data segments): +// 0x200 5 "name:" +// 0x206 5 "addr:" +// 0x20C 12 "registered: " +// 0x218 12 "name taken: " +// 0x224 11 "not found: " +// 0x230 7 "owner: " +// 0x238 13 "transferred: " +// 0x246 14 "unauthorized: " +// 0x255 10 "released: " +// 0x260 6 "name: " +// 0x267 9 "no name: " +// 0x271 14 "insufficient: " +// 0x280 13 "fee: 10000000" +// 0x28D 12 "fee: 1000000" +// 0x299 11 "fee: 100000" +// 0x2A4 10 "fee: 10000" +// 0x2AE 9 "fee: 1000" +// 0x2B7 8 "fee: 100" +// +// 0x300 256 scratch buffer (used by buildKey + logPrefixName) + +const ( + offArg0 int32 = 0x000 + offArg1 int32 = 0x040 + offCaller int32 = 0x0C0 + offTreasury int32 = 0x140 + offStateRead int32 = 0x180 + + offPfxName int32 = 0x200 // "name:" 5 bytes + offPfxAddr int32 = 0x206 // "addr:" 5 bytes + + offRegisteredPfx int32 = 0x20C // "registered: " 12 + offNameTakenPfx int32 = 0x218 // "name taken: " 12 + offNotFoundPfx int32 = 0x224 // "not found: " 11 + offOwnerPfx int32 = 0x230 // "owner: " 7 + offTransferredPfx int32 = 0x238 // "transferred: " 13 + offUnauthPfx int32 = 0x246 // "unauthorized: " 14 + offReleasedPfx int32 = 0x255 // "released: " 10 + offNamePfx int32 = 0x260 // "name: " 6 + offNoNamePfx int32 = 0x267 // "no name: " 9 + offInsuffPfx int32 = 0x271 // "insufficient: " 14 + + offFee1 int32 = 0x280 // "fee: 10000000" 13 + offFee2 int32 = 0x28D // "fee: 1000000" 12 + offFee3 int32 = 0x299 // "fee: 100000" 11 + offFee4 int32 = 0x2A4 // "fee: 10000" 10 + offFee5 int32 = 0x2AE // "fee: 1000" 9 + offFee6 int32 = 0x2B7 // "fee: 100" 8 + + offScratch int32 = 0x300 +) + +// ── Import / function indices ───────────────────────────────────────────────── + +const ( + // imports (0-based) + fnGetArgStr = 0 + fnGetCaller = 1 + fnGetState = 2 + fnSetState = 3 + fnLog = 4 + fnTransfer = 5 + fnGetBalance = 6 + fnGetContractTreasury = 7 + + // local functions (after 8 imports) + fnBytesEqual = 8 + fnMemcpy = 9 + fnLogPrefixName = 10 + fnCalcFee = 11 + fnBuildKey = 12 + fnRegister = 13 + fnResolve = 14 + fnLookup = 15 + fnTransferFn = 16 + fnRelease = 17 + fnFee = 18 +) + +// ── Helper function bodies ──────────────────────────────────────────────────── + +// $bytes_equal(aPtr, bPtr, len i32) → i32 (1=equal, 0=not) +// extra locals: i(3), same(4) +func bytesEqualBody() []byte { + return funcBody( + withLocals(localDecl(2, tI32)), + ic32(1), lset(4), + ic32(0), lset(3), + block_(), + loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), + lget(0), lget(3), i32Add(), i32Load8U(), + lget(1), lget(3), i32Add(), i32Load8U(), + i32Ne(), if_(), + ic32(0), lset(4), br_(2), + end_(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), + end_(), + end_(), + lget(4), + ) +} + +// $memcpy(dst, src, len i32) +// extra local: i(3) +func memcpyBody() []byte { + return funcBody( + withLocals(localDecl(1, tI32)), + ic32(0), lset(3), + block_(), + loop_(), + lget(3), lget(2), i32GeU(), brIf_(1), + lget(0), lget(3), i32Add(), + lget(1), lget(3), i32Add(), i32Load8U(), + i32Store8(), + lget(3), ic32(1), i32Add(), lset(3), + br_(0), + end_(), + end_(), + ) +} + +// $log_prefix_name(prefixPtr, prefixLen, suffixPtr, suffixLen i32) +// Concatenates prefix+suffix into scratch and logs the result. +func logPrefixNameBody() []byte { + return funcBody( + noLocals, + ic32(offScratch), lget(0), lget(1), call(fnMemcpy), + ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy), + ic32(offScratch), lget(1), lget(3), i32Add(), call(fnLog), + ) +} + +// $calc_fee(nameLen i32) → i64 +// Fee tiers: 1→10M, 2→1M, 3→100K, 4→10K, 5→1K, ≥6→100 µT +func calcFeeBody() []byte { + return funcBody( + noLocals, + lget(0), ic32(1), i32LeU(), if_(), + ic64(10_000_000), return_(), + end_(), + lget(0), ic32(2), i32Ne(), if_(), // len>=3 + lget(0), ic32(3), i32Ne(), if_(), // len>=4 + lget(0), ic32(4), i32Ne(), if_(), // len>=5 + lget(0), ic32(5), i32Ne(), if_(), // len>=6 + ic64(100), return_(), + end_(), + ic64(1_000), return_(), + end_(), + ic64(10_000), return_(), + end_(), + ic64(100_000), return_(), + end_(), + ic64(1_000_000), + ) +} + +// $build_key(pfxOff, pfxLen, dataOff, dataLen i32) → keyLen i32 +// Writes pfx+data into scratch (offScratch) and returns total length. +func buildKeyBody() []byte { + return funcBody( + noLocals, + ic32(offScratch), lget(0), lget(1), call(fnMemcpy), + ic32(offScratch), lget(1), i32Add(), lget(2), lget(3), call(fnMemcpy), + lget(1), lget(3), i32Add(), + ) +} + +// isOwnerCheck emits code that, given two length locals, leaves i32(1=owner,0=not) on stack. +// Assumes caller bytes are at offCaller and existing owner bytes at offStateRead. +func isOwnerCheck(callerLenLocal, existingLenLocal uint32) []byte { + return cat( + lget(callerLenLocal), lget(existingLenLocal), i32Ne(), + ifI32(), + ic32(0), + else_(), + ic32(offCaller), ic32(offStateRead), lget(callerLenLocal), call(fnBytesEqual), + end_(), + ) +} + +// ── Contract method bodies ──────────────────────────────────────────────────── + +// register(name string) +// Locals (i32): nameLen(0), callerLen(1), existingLen(2), treasuryLen(3), keyLen(4) +// Locals (i64): fee(5) +func registerBody() []byte { + return funcBody( + withLocals(localDecl(5, tI32), localDecl(1, tI64)), + + // Read name arg + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Check name not already taken + ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offStateRead), ic32(128), call(fnGetState), lset(2), + lget(2), ic32(0), i32GtU(), if_(), + ic32(offNameTakenPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + // Get caller + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + + // Compute fee + lget(0), call(fnCalcFee), lset(5), + + // Check balance >= fee + ic32(offCaller), lget(1), call(fnGetBalance), + lget(5), i64LtU(), if_(), + ic32(offInsuffPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + // Get treasury address + ic32(offTreasury), ic32(64), call(fnGetContractTreasury), lset(3), + + // Transfer fee: caller → treasury + ic32(offCaller), lget(1), ic32(offTreasury), lget(3), lget(5), call(fnTransfer), drop(), + + // Write name→owner + ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offCaller), lget(1), call(fnSetState), + + // Write addr→name (reverse index) + ic32(offPfxAddr), ic32(5), ic32(offCaller), lget(1), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offArg0), lget(0), call(fnSetState), + + // Log success + ic32(offRegisteredPfx), ic32(12), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) +} + +// resolve(name string) +// Locals: nameLen(0), ownerLen(1), keyLen(2) +func resolveBody() []byte { + return funcBody( + withLocals(localDecl(3, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(2), + ic32(offScratch), lget(2), ic32(offStateRead), ic32(128), call(fnGetState), lset(1), + lget(1), i32Eqz(), if_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + ic32(offOwnerPfx), ic32(7), ic32(offStateRead), lget(1), call(fnLogPrefixName), + ) +} + +// lookup(address string) — reverse lookup address → name +// Locals: addrLen(0), nameLen(1), keyLen(2) +func lookupBody() []byte { + return funcBody( + withLocals(localDecl(3, tI32)), + ic32(0), ic32(offArg0), ic32(128), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + ic32(offPfxAddr), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(2), + ic32(offScratch), lget(2), ic32(offStateRead), ic32(128), call(fnGetState), lset(1), + lget(1), i32Eqz(), if_(), + ic32(offNoNamePfx), ic32(9), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + ic32(offNamePfx), ic32(6), ic32(offStateRead), lget(1), call(fnLogPrefixName), + ) +} + +// transfer(name, new_owner) — transfer username ownership +// Locals: nameLen(0), newOwnerLen(1), callerLen(2), existingLen(3), keyLen(4) +func transferBody() []byte { + return funcBody( + withLocals(localDecl(5, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + ic32(1), ic32(offArg1), ic32(128), call(fnGetArgStr), lset(1), + lget(1), i32Eqz(), if_(), return_(), end_(), + + // Look up existing owner + ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offStateRead), ic32(128), call(fnGetState), lset(3), + lget(3), i32Eqz(), if_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + // Verify caller is the current owner + ic32(offCaller), ic32(128), call(fnGetCaller), lset(2), + isOwnerCheck(2, 3), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + // Delete old reverse index: addr: → "" + ic32(offPfxAddr), ic32(5), ic32(offStateRead), lget(3), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offScratch), ic32(0), call(fnSetState), + + // Update forward index: name: → newOwner + ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offArg1), lget(1), call(fnSetState), + + // Add new reverse index: addr: → name + ic32(offPfxAddr), ic32(5), ic32(offArg1), lget(1), call(fnBuildKey), lset(4), + ic32(offScratch), lget(4), ic32(offArg0), lget(0), call(fnSetState), + + ic32(offTransferredPfx), ic32(13), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) +} + +// release(name) — release a username registration +// Locals: nameLen(0), callerLen(1), existingLen(2), keyLen(3) +func releaseBody() []byte { + return funcBody( + withLocals(localDecl(4, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(3), + ic32(offScratch), lget(3), ic32(offStateRead), ic32(128), call(fnGetState), lset(2), + lget(2), i32Eqz(), if_(), + ic32(offNotFoundPfx), ic32(11), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + ic32(offCaller), ic32(128), call(fnGetCaller), lset(1), + isOwnerCheck(1, 2), + i32Eqz(), if_(), + ic32(offUnauthPfx), ic32(14), ic32(offArg0), lget(0), call(fnLogPrefixName), + return_(), + end_(), + + // Delete reverse index: addr: → "" + ic32(offPfxAddr), ic32(5), ic32(offStateRead), lget(2), call(fnBuildKey), lset(3), + ic32(offScratch), lget(3), ic32(offScratch), ic32(0), call(fnSetState), + + // Delete forward index: name: → "" + ic32(offPfxName), ic32(5), ic32(offArg0), lget(0), call(fnBuildKey), lset(3), + ic32(offScratch), lget(3), ic32(offScratch), ic32(0), call(fnSetState), + + ic32(offReleasedPfx), ic32(10), ic32(offArg0), lget(0), call(fnLogPrefixName), + ) +} + +// fee(name) — log the registration fee for a given name +// Uses pre-baked static strings; no arithmetic needed. +// Locals: nameLen(0) +func feeBody() []byte { + return funcBody( + withLocals(localDecl(1, tI32)), + ic32(0), ic32(offArg0), ic32(64), call(fnGetArgStr), lset(0), + lget(0), i32Eqz(), if_(), return_(), end_(), + + // Branch on name length and log the matching static fee string. + lget(0), ic32(1), i32LeU(), if_(), + ic32(offFee1), ic32(13), call(fnLog), return_(), + end_(), + lget(0), ic32(2), i32Ne(), if_(), // len >= 3 + lget(0), ic32(3), i32Ne(), if_(), // len >= 4 + lget(0), ic32(4), i32Ne(), if_(), // len >= 5 + lget(0), ic32(5), i32Ne(), if_(), // len >= 6 + ic32(offFee6), ic32(8), call(fnLog), return_(), + end_(), + ic32(offFee5), ic32(9), call(fnLog), return_(), + end_(), + ic32(offFee4), ic32(10), call(fnLog), return_(), + end_(), + ic32(offFee3), ic32(11), call(fnLog), return_(), + end_(), + // len == 2 + ic32(offFee2), ic32(12), call(fnLog), + ) +} + +// ── main ────────────────────────────────────────────────────────────────────── + +func main() { + // ── Type table ──────────────────────────────────────────────────────────── + // 0: (i32,i32,i32)→(i32) get_arg_str, bytes_equal + // 1: (i32,i32)→(i32) get_caller, get_contract_treasury + // 2: (i32,i32,i32,i32)→(i32) get_state, build_key + // 3: (i32,i32,i32,i32)→() set_state, log_prefix_name + // 4: (i32,i32)→() log + // 5: (i32,i32,i32,i32,i64)→(i32) transfer + // 6: (i32,i32)→(i64) get_balance + // 7: ()→() exported methods + // 8: (i32,i32,i32)→() memcpy + // 9: (i32)→(i64) calc_fee + typeSection := section(0x01, vec( + functype([]byte{tI32, tI32, tI32}, []byte{tI32}), // 0 + functype([]byte{tI32, tI32}, []byte{tI32}), // 1 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{tI32}), // 2 + functype([]byte{tI32, tI32, tI32, tI32}, []byte{}), // 3 + functype([]byte{tI32, tI32}, []byte{}), // 4 + functype([]byte{tI32, tI32, tI32, tI32, tI64}, []byte{tI32}), // 5 + functype([]byte{tI32, tI32}, []byte{tI64}), // 6 + functype([]byte{}, []byte{}), // 7 + functype([]byte{tI32, tI32, tI32}, []byte{}), // 8 + functype([]byte{tI32}, []byte{tI64}), // 9 + )) + + importSection := section(0x02, vec( + importFunc("env", "get_arg_str", 0), // 0 type 0 + importFunc("env", "get_caller", 1), // 1 type 1 + importFunc("env", "get_state", 2), // 2 type 2 + importFunc("env", "set_state", 3), // 3 type 3 + importFunc("env", "log", 4), // 4 type 4 + importFunc("env", "transfer", 5), // 5 type 5 + importFunc("env", "get_balance", 6), // 6 type 6 + importFunc("env", "get_contract_treasury", 1), // 7 type 1 + )) + + // 11 local functions + functionSection := section(0x03, vec( + u(0), // bytes_equal type 0 + u(8), // memcpy type 8 + u(3), // log_prefix_name type 3 + u(9), // calc_fee type 9 + u(2), // build_key type 2 + u(7), // register type 7 + u(7), // resolve type 7 + u(7), // lookup type 7 + u(7), // transfer_fn type 7 + u(7), // release type 7 + u(7), // fee type 7 + )) + + memorySection := section(0x05, vec(cat([]byte{0x00}, u(1)))) + + exportSection := section(0x07, vec( + exportEntry("memory", 0x02, 0), + exportEntry("register", 0x00, fnRegister), + exportEntry("resolve", 0x00, fnResolve), + exportEntry("lookup", 0x00, fnLookup), + exportEntry("transfer", 0x00, fnTransferFn), + exportEntry("release", 0x00, fnRelease), + exportEntry("fee", 0x00, fnFee), + )) + + dataSection := section(0x0B, cat( + u(19), + dataSegment(offPfxName, []byte("name:")), + dataSegment(offPfxAddr, []byte("addr:")), + dataSegment(offRegisteredPfx, []byte("registered: ")), + dataSegment(offNameTakenPfx, []byte("name taken: ")), + dataSegment(offNotFoundPfx, []byte("not found: ")), + dataSegment(offOwnerPfx, []byte("owner: ")), + dataSegment(offTransferredPfx, []byte("transferred: ")), + dataSegment(offUnauthPfx, []byte("unauthorized: ")), + dataSegment(offReleasedPfx, []byte("released: ")), + dataSegment(offNamePfx, []byte("name: ")), + dataSegment(offNoNamePfx, []byte("no name: ")), + dataSegment(offInsuffPfx, []byte("insufficient: ")), + dataSegment(offFee1, []byte("fee: 10000000")), + dataSegment(offFee2, []byte("fee: 1000000")), + dataSegment(offFee3, []byte("fee: 100000")), + dataSegment(offFee4, []byte("fee: 10000")), + dataSegment(offFee5, []byte("fee: 1000")), + dataSegment(offFee6, []byte("fee: 100")), + // pad entry to make count = 19 (u(19) above) — actually 18 strings above + // Let me count: pfxName, pfxAddr, registered, nameTaken, notFound, owner, + // transferred, unauth, released, name, noName, insuff, fee1..fee6 = 18 + // Fix: change u(19) → u(18) — corrected below in final assembly. + )) + // Recount: 18 data segments total — rebuild without the u() wrapper mismatch. + dataSection = section(0x0B, cat( + u(18), + dataSegment(offPfxName, []byte("name:")), + dataSegment(offPfxAddr, []byte("addr:")), + dataSegment(offRegisteredPfx, []byte("registered: ")), + dataSegment(offNameTakenPfx, []byte("name taken: ")), + dataSegment(offNotFoundPfx, []byte("not found: ")), + dataSegment(offOwnerPfx, []byte("owner: ")), + dataSegment(offTransferredPfx, []byte("transferred: ")), + dataSegment(offUnauthPfx, []byte("unauthorized: ")), + dataSegment(offReleasedPfx, []byte("released: ")), + dataSegment(offNamePfx, []byte("name: ")), + dataSegment(offNoNamePfx, []byte("no name: ")), + dataSegment(offInsuffPfx, []byte("insufficient: ")), + dataSegment(offFee1, []byte("fee: 10000000")), + dataSegment(offFee2, []byte("fee: 1000000")), + dataSegment(offFee3, []byte("fee: 100000")), + dataSegment(offFee4, []byte("fee: 10000")), + dataSegment(offFee5, []byte("fee: 1000")), + dataSegment(offFee6, []byte("fee: 100")), + )) + + codeSection := section(0x0A, cat( + u(11), + bytesEqualBody(), + memcpyBody(), + logPrefixNameBody(), + calcFeeBody(), + buildKeyBody(), + registerBody(), + resolveBody(), + lookupBody(), + transferBody(), + releaseBody(), + feeBody(), + )) + + module := cat( + []byte{0x00, 0x61, 0x73, 0x6d}, + []byte{0x01, 0x00, 0x00, 0x00}, + typeSection, + importSection, + functionSection, + memorySection, + exportSection, + dataSection, + codeSection, + ) + + out := "contracts/username_registry/username_registry.wasm" + if err := os.WriteFile(out, module, 0644); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } + fmt.Printf("Written %s (%d bytes)\n", out, len(module)) +} diff --git a/contracts/username_registry/username_registry.wasm b/contracts/username_registry/username_registry.wasm new file mode 100644 index 0000000000000000000000000000000000000000..58bafd0938fa0694ed62598ea9e9067c4ca108ef GIT binary patch literal 1719 zcmah}zmFS56n^t-ZSS-1mPj;+V)hRJL3B1!`% zQ6?QiX+fbPrK`}Q<8fY* z@PUpGOzW)9Nd|wq_LcY5xI888t#(~y#dL^`#=8Ogi)@?~$2mnl@@l{?3*;TwM|G8F z(^+*FWx7ml)se06GfI$Xz2Ax^`J}AwN@`d6>1bL5x7VujX*s@=(_S<#%iFWBXn#{R z!Ri>8IlZEWc}}ra4~m3K&E^I9_2;tt6UiFLCk@D`>o}V+@;~}c8J_V!@Ly%bHni_K<;P5 zowZNySHtbbjj!7Lu8?ylQtc>JvakA9S@~&CShDKu4I(R+^GCXm4BeG%l@n*F>Jqcn z{H74|U0a)d2Y}dY;1sGNV2h;V4RL0rRVy2X63rhHl#5Sv|M23cKlMJtlPHc!EhoL4 z45q`sXrB-NZejTTcW?f18DU)9dxu}&T`17v--I-H36AhzP(mqcexUpNj0@w8gO_yC z5fH?n+=WPFDy?+%F}mIlT?+{y9&Z4l ziqdsk0+(f5b^|`wuCoTb(k^NM{}96LqA1V~-#S9;Jf$0ZudKSpy6HU5#o}h5hVPay zE0Z?+^rzeX4+^lTwoW#3zMa>PeBtkBa{%6c=>VQ+*ISC^Uxe_bZ#n@~i0}lO69C`; z)Ph!6T=p$3;wCK;#2WN#TK>K8OuKkRc@kdi%Zyi#4&NNtOaABC;Tz)GJ_+9z*9pcW R(IDX' or 'not found: '.", + "args": [ + {"name": "name", "type": "string", "description": "Username to look up"} + ] + }, + { + "name": "lookup", + "description": "Reverse lookup: find the username registered to a given address. Logs 'name: ' or 'no name:
'.", + "args": [ + {"name": "address", "type": "string", "description": "Wallet address (hex pubkey)"} + ] + }, + { + "name": "transfer", + "description": "Transfer ownership of a username to another address. Only the current owner may call this.", + "args": [ + {"name": "name", "type": "string", "description": "Username to transfer"}, + {"name": "new_owner", "type": "string", "description": "New owner address (hex pubkey)"} + ] + }, + { + "name": "release", + "description": "Release a username registration. Only the current owner may call this.", + "args": [ + {"name": "name", "type": "string", "description": "Username to release"} + ] + }, + { + "name": "fee", + "description": "Query the registration fee for a given name length. Logs 'fee: ' in µT.", + "args": [ + {"name": "name", "type": "string", "description": "Name whose fee you want to check"} + ] + } + ] +} diff --git a/deploy/UPDATE_STRATEGY.md b/deploy/UPDATE_STRATEGY.md new file mode 100644 index 0000000..c277e8f --- /dev/null +++ b/deploy/UPDATE_STRATEGY.md @@ -0,0 +1,339 @@ +# DChain node — update & seamless-upgrade strategy + +Этот документ отвечает на два вопроса: + +1. **Как оператор ноды обновляет её от git-сервера** (pull → build → restart) без + простоя и без потери данных. +2. **Как мы сохраняем бесшовную совместимость** между версиями ноды, чтобы не + пришлось "ломать" старых клиентов, чужие ноды или собственную историю. + +Читается в связке с `deploy/single/README.md` (операционный runbook) и +`CHANGELOG.md` (что уже зашипплено). + +--- + +## 1. Слои, которые надо развести + +| Слой | Что ломает совместимость | Кто страдает | Как закрыто | +|---------------------|--------------------------------------------------------------------|-----------------------|----------------------| +| **Wire-протокол** | gossipsub topic name, tx encoding, PBFT message format | P2P-сеть целиком | §3. Versioned topics | +| **HTTP/WS API** | эндпоинт меняет схему, WS op исчезает | Клиенты (mobile, web) | §4. API versioning | +| **Chain state** | новый EventType в блоке, новое поле в TxRecord | Joiner'ы, валидаторы | §5. Chain upgrade | +| **Storage layout** | BadgerDB prefix переименован, ключи перемешались | Сам бинарь при старте | §6. DB migrations | +| **Docker image** | пересобрать образ, поменять флаги | Только локально | §2. Rolling restart | + +**Главный принцип:** любое изменение проходит как **минимум два релиза** — +сначала *"понимаем оба формата, пишем новый"*, потом *"не умеем старый"*. +Между ними время, за которое оператор обновляется. + +--- + +## 2. Rolling-restart от git-сервера (single-node) + +### 2.1. Скрипт `deploy/single/update.sh` + +Оператор ставит **один cron/systemd-timer** который дергает этот скрипт: + +```bash +#!/usr/bin/env bash +# deploy/single/update.sh — pull-and-restart update for a single DChain node. +# Safe to run unattended: no-op if git HEAD didn't move. +set -euo pipefail + +REPO_DIR="${REPO_DIR:-/opt/dchain}" +IMAGE_TAG="${IMAGE_TAG:-dchain-node-slim}" +CONTAINER="${CONTAINER:-dchain_node}" + +cd "$REPO_DIR" +git fetch --quiet origin main +local=$(git rev-parse HEAD) +remote=$(git rev-parse origin/main) +[[ "$local" = "$remote" ]] && { echo "up to date: $local"; exit 0; } + +echo "updating $local → $remote" + +# 1. rebuild +docker build --quiet -t "$IMAGE_TAG:$remote" -t "$IMAGE_TAG:latest" \ + -f deploy/prod/Dockerfile.slim . + +# 2. smoke-test new image BEFORE killing the running one +docker run --rm --entrypoint /usr/local/bin/node "$IMAGE_TAG:$remote" --version \ + >/dev/null || { echo "new image fails smoke test"; exit 1; } + +# 3. checkpoint the DB (cheap cp-on-write snapshot via badger) +curl -fs "http://127.0.0.1:8080/api/admin/checkpoint" \ + -H "Authorization: Bearer $DCHAIN_API_TOKEN" \ + || echo "checkpoint failed, continuing anyway" + +# 4. stop-start with the SAME volume + env +git log -1 --pretty='update: %h %s' > .last-update +docker compose -f deploy/single/docker-compose.yml up -d --force-recreate node + +# 5. wait for health +for i in {1..30}; do + curl -fsS http://127.0.0.1:8080/api/netstats >/dev/null && { echo ok; exit 0; } + sleep 2 +done +echo "new container did not become healthy" +docker logs "$CONTAINER" | tail -40 +exit 1 +``` + +### 2.2. systemd таймер + +```ini +# /etc/systemd/system/dchain-update.service +[Unit] +Description=DChain node pull-and-restart +[Service] +Type=oneshot +EnvironmentFile=/opt/dchain/deploy/single/node.env +ExecStart=/opt/dchain/deploy/single/update.sh + +# /etc/systemd/system/dchain-update.timer +[Unit] +Description=Pull DChain updates hourly +[Timer] +OnCalendar=hourly +RandomizedDelaySec=15min +Persistent=true +[Install] +WantedBy=timers.target +``` + +`RandomizedDelaySec=15min` — чтобы куча нод на одной сети не перезапускалась +одновременно, иначе на момент обновления PBFT quorum может упасть. + +### 2.3. Даунтайм одной ноды + +| Шаг | Время | Можно ли слать tx? | +|-------------------|-------|--------------------| +| docker build | 30-90s| да (старая ещё работает) | +| docker compose up | 2-5s | нет (transition) | +| DB open + replay | 1-3s | нет | +| healthy | — | да | + +**Итого ~5-8 секунд простоя на одну ноду.** Клиент (React Native) уже +реконнектится по WS автоматически (см. `client-app/lib/ws.ts` — retry loop, +max 30s backoff). + +### 2.4. Multi-node rolling (для будущего кластера) + +Когда появится 3+ валидаторов: update скрипт должен обновлять **по одному** с +паузой между ними больше, чем health-check interval. В `deploy/prod/` есть +`docker-compose.yml` с тремя нодами — там эквивалент выглядит как: + +```bash +for n in node1 node2 node3; do + docker compose up -d --force-recreate "$n" + for i in {1..30}; do + curl -fs "http://127.0.0.1:808${n: -1}/api/netstats" >/dev/null && break + sleep 2 + done +done +``` + +Пока в сети 2/3 валидатора живы, PBFT quorum не падает и блоки продолжают +коммититься. Единственная нода, которая обновляется, пропустит 1-2 блока и +догонит их через gossip gap-fill (уже работает, см. `p2p/host.go` → GetBlocks). + +--- + +## 3. Wire-протокол: versioned topics + +Текущие gossipsub-топики: + +``` +dchain/tx/v1 +dchain/blocks/v1 +dchain/relay/v1 +``` + +`/v1` суффикс — это не формальность, это **рельса под миграцию**. Когда +появится несовместимое изменение (напр. новый PBFT round format): + +1. Релиз N: нода подписана на ОБА топика `dchain/blocks/v1` и `dchain/blocks/v2`. + Публикует в v2, читает из обоих. +2. Релиз N+1 (после того, как оператор видит в `/api/netstats` что все 100% + пиров ≥ N): нода перестаёт читать v1. +3. Релиз N+2: v1 удаляется из кода. + +Между N и N+2 должно пройти **минимум 30 дней**. За это время у каждого оператора +хоть раз сработает auto-update. + +--- + +## 4. API versioning + +### Уже есть: + +- `/api/*` — v1, "explorer API", stable contract +- `/v2/chain/*` — специальная секция для tonapi-подобных клиентов (tonapi совместимость) + +### Правило на будущее: + +1. **Только добавляем поля** к существующим ответам. JSON-клиент не падает от + незнакомого поля — Go unmarshal игнорирует, TypeScript через `unknown` каст + тоже. Никогда не переименовываем и не удаляем. +2. Если нужна breaking-change — новый префикс. Например, если CreateChannelPayload + меняет формат, появляется `/v2/channels/*`. Старый `/api/channels/*` сохраняется + как read-only adapter поверх нового стораджа. +3. **Deprecation header:** когда старый эндпоинт переведён на адаптер, добавить + `Warning: 299 - "use /v2/channels/* instead, this will be removed 2026-06-01"`. +4. **Клиент сам определяет версию** через `/api/well-known-version`: + ```json + { "node_version": "0.5.0", "protocol_version": 3, "features": ["channels_v1", "fan_out"] } + ``` + Клиент в `client-app/lib/api.ts` кеширует ответ и знает, что можно звать. + Уже есть `/api/well-known-contracts` как прецедент; `/api/well-known-version` + добавляется одной функцией. + +### Клиентская сторона — graceful degradation: + +- WebSocket: если op `submit_tx` вернул `{error: "unknown_op"}`, fallback на + HTTP POST /api/tx. +- HTTP: fetch'и обёрнуты в try/catch в `api.ts`, 404 на новом эндпоинте → + скрыть фичу в UI (feature-flag), не падать. +- **Chain-ID check:** уже есть (`client-app/lib/api.ts` → `networkInfo()`), + если нода сменила chain_id — клиент очищает кеш и пересинкается. + +--- + +## 5. Chain state upgrade + +Самый болезненный слой: если блок N+1 содержит EventType, который старая нода +не умеет обрабатывать, она **отклонит** весь блок и отвалится от консенсуса. + +### 5.1. Strict forward-compatibility правила для EventType + +```go +// ApplyTx в blockchain/chain.go +switch ev.Type { +case EventTransfer: ... +case EventRegisterRelay: ... +case EventCreateChannel: ... +// ... +case EventFutureFeatureWeDontHave: + // ← НЕ возвращать error! ЭТО крашнет валидатор на своём же блоке + // иначе. + // Правило: неизвестный event type === no-op + warn. Tx включается в блок, + // fee списывается, результат = ничего не изменилось. + chain.log.Warn("unknown event type", "type", ev.Type, "tx", tx.ID) + return nil +} +``` + +**Проверить:** сейчас `ApplyTx` в `blockchain/chain.go` падает на unknown event. +Это приоритетный fix для seamless — добавить в план. + +### 5.2. Feature activation flags + +Новый EventType добавляется в два этапа: + +1. **Release A:** бинарь умеет `EventChannelBan`, но **не пропускает** его в + мемпул, пока не увидит в chain state запись `feature:channel_ban:enabled`. + Эту запись создаёт одна "activation tx" от валидаторов (multi-sig). +2. **Release B (через 30+ дней):** операторы, у которых автопуллится, получили + Release A. Один валидатор подаёт activation tx — она пишет в state, все + остальные validate её, ОК. +3. С этого момента `EventChannelBan` легален. Старые ноды (кто не обновился) + отклонят activation tx → отвалятся от консенсуса. Это сознательно: они и + так не понимают новый event, лучше явная ошибка "обновись", чем silent + divergence. + +Прототип в `blockchain/types.go` уже есть — `chain.GovernanceContract` может +хранить feature flags. Нужен конкретный helper `chain.FeatureEnabled(name)`. + +### 5.3. Genesis hash pin + +Новая нода при `--join` скачивает `/api/network-info`, читает `genesis_hash`, +сравнивает со своим (пустым, т.к. чистый старт). Если в сети уже есть другой +genesis — ошибка `FATAL: genesis hash mismatch`. Это защита от случайного +фарка при опечатке в `DCHAIN_JOIN`. Работает сейчас, не трогать. + +--- + +## 6. DB migrations (BadgerDB) + +Правила работы с префиксами: + +```go +const ( + prefixTx = "tx:" + prefixChannel = "chan:" + prefixSchemaVer = "schema:v" // ← meta-ключ, хранит текущую версию схемы +) +``` + +При старте: + +```go +cur := chain.ReadSchemaVersion() // default 0 если ключ отсутствует +for cur < TargetSchemaVersion { + switch cur { + case 0: + // migration 0→1: rename prefix "member:" → "chan_mem:" + migrate_0_to_1(db) + case 1: + // migration 1→2: add x25519_pub column to IdentityInfo + migrate_1_to_2(db) + } + cur++ + chain.WriteSchemaVersion(cur) +} +``` + +**Свойства миграции:** + +- Идемпотентна: если упала посередине, повторный старт доделает. +- Однонаправлена: downgrade → надо восстанавливать из backup. Это OK, документируется. +- Бэкап перед миграцией: `update.sh` из §2.1 делает `/api/admin/checkpoint` до + перезапуска. (Этот endpoint надо ещё реализовать — сейчас его нет.) +- Первая миграция, которую надо сделать — завести сам mechanism, даже если + `TargetSchemaVersion = 0`. Чтобы следующая breaking-change могла им + воспользоваться. + +--- + +## 7. Что сделать сейчас, чтобы "не пришлось ничего ломать в будущем" + +Минимальный чек-лист, **отсортирован по приоритету**: + +### P0 (до следующего release): + +- [ ] `ApplyTx`: unknown EventType → warn + no-op, НЕ error. (§5.1) +- [ ] `/api/well-known-version` endpoint (§4). Тривиально, 20 строк. +- [ ] Schema version meta-ключ в BadgerDB, даже если `current = 0`. (§6) +- [ ] `deploy/single/update.sh` + systemd timer примеры. (§2) + +### P1 (до 1.0): + +- [ ] `chain.FeatureEnabled(name)` helper + документ activation flow. (§5.2) +- [ ] `/api/admin/checkpoint` endpoint (за token-guard), делает `db.Flatten` + + создаёт snapshot в `/data/snapshots//`. (§2.1) +- [ ] Deprecation-header механизм в HTTP middleware. (§4) +- [ ] CI smoke-test: "новый бинарь поверх старого volume" — проверяет что + миграции не ломают данные. + +### P2 (nice-to-have): + +- [ ] Multi-version e2e test в `cmd/loadtest`: два процесса на разных HEAD, + убедиться что они в консенсусе. +- [ ] `go-blockchain/pkg/migrate/` отдельный пакет с registry migrations. + +--- + +## 8. Короткий ответ на вопрос + +> надо подумать на счёт синхронизации и обновления ноды с гит сервера, а так +> же бесшовности, чтобы не пришлось ничего ломать в будущем + +1. **Синхронизация с git:** `deploy/single/update.sh` + systemd timer раз в час, + ~5-8 секунд даунтайма на single-node. +2. **Бесшовность:** 4 слоя, каждый со своим правилом расширения без + ломания — versioned topics, additive-only API, feature-flag activation + для новых EventType, schema-versioned БД. +3. **P0-тикеты выше** (4 штуки, маленькие) закрывают "семплинг worst case": + unknown event как no-op, version endpoint, schema-version key в БД, + update-скрипт. Этого достаточно чтобы следующие 3-5 релизов прошли без + breaking-change. diff --git a/deploy/prod/Dockerfile.slim b/deploy/prod/Dockerfile.slim new file mode 100644 index 0000000..f349cfc --- /dev/null +++ b/deploy/prod/Dockerfile.slim @@ -0,0 +1,63 @@ +# Production image for dchain-node. +# +# Differs from the repo-root Dockerfile in two ways: +# 1. No testdata / contract WASMs baked in — a fresh node uses the native +# username_registry (shipped in-binary) and starts with an empty keys +# directory; identities and optional WASM contracts come in via +# mounted volumes or docker-compose bind mounts. +# 2. Builds only `node` and `client` — no wallet/peerid helpers that +# aren't needed in production. +# +# The resulting image is ~20 MB vs ~60 MB for the dev one, and has no +# pre-installed keys that an attacker could exploit to impersonate a +# testnet validator. + +# ---- build stage ---- +FROM golang:1.24-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build-time version metadata. All four args are injected via -ldflags -X +# into go-blockchain/node/version so `node --version` and +# /api/well-known-version report the real commit, not the "dev" default. +# Callers pass these with `docker build --build-arg VERSION_TAG=... …`; +# the deploy/single/update.sh script derives them from git automatically. +ARG VERSION_TAG=dev +ARG VERSION_COMMIT=none +ARG VERSION_DATE=unknown +ARG VERSION_DIRTY=false + +RUN LDFLAGS="-s -w \ + -X go-blockchain/node/version.Tag=${VERSION_TAG} \ + -X go-blockchain/node/version.Commit=${VERSION_COMMIT} \ + -X go-blockchain/node/version.Date=${VERSION_DATE} \ + -X go-blockchain/node/version.Dirty=${VERSION_DIRTY}" && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/node ./cmd/node && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="$LDFLAGS" -o /bin/client ./cmd/client + +# ---- runtime stage ---- +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +# Run as unprivileged user by default. Operators can override with --user root +# if they need to bind privileged ports (shouldn't be necessary behind Caddy). +RUN addgroup -S dchain && adduser -S -G dchain dchain + +COPY --from=builder /bin/node /usr/local/bin/node +COPY --from=builder /bin/client /usr/local/bin/client + +USER dchain + +# Default data location; override in compose with a named volume. +VOLUME /data + +# libp2p P2P port + HTTP (serves /api/*, /metrics, /api/ws). +EXPOSE 4001/tcp +EXPOSE 8080/tcp + +ENTRYPOINT ["/usr/local/bin/node"] diff --git a/deploy/prod/README.md b/deploy/prod/README.md new file mode 100644 index 0000000..07ffdbb --- /dev/null +++ b/deploy/prod/README.md @@ -0,0 +1,163 @@ +# DChain production deployment + +Turn-key-ish stack: 3 validators + Caddy TLS edge + optional +Prometheus/Grafana, behind auto-HTTPS. + +## Prerequisites + +- Docker + Compose v2 +- A public IP and open ports `80`, `443`, `4001` (libp2p) on every host +- DNS `A`-record pointing `DOMAIN` at the host running Caddy +- Basic familiarity with editing env files + +## Layout (single-host pilot) + +``` + ┌─ Caddy :443 ── TLS terminate ──┬─ node1:8080 ──┐ + internet ────────→│ ├─ node2:8080 │ round-robin /api/* + └─ Caddy :4001 (passthrough) └─ node3:8080 │ ip_hash /api/ws + ... + Prometheus → node{1,2,3}:8080/metrics + Grafana ← Prometheus data source +``` + +For a real multi-datacentre deployment, copy this whole directory onto each +VPS, edit `docker-compose.yml` to keep only the node that runs there, and +put Caddy on one dedicated edge host (or none — point clients at one node +directly and accept the lower availability). + +## First-boot procedure + +1. **Generate keys** for each validator. Easiest way: + + ```bash + # On any box with the repo checked out + docker build -t dchain-node-slim -f deploy/prod/Dockerfile.slim . + mkdir -p deploy/prod/keys + for i in 1 2 3; do + docker run --rm -v "$PWD/deploy/prod/keys:/out" dchain-node-slim \ + /usr/local/bin/client keygen --out /out/node$i.json + done + cat deploy/prod/keys/node*.json | jq -r .pub_key # → copy into DCHAIN_VALIDATORS + ``` + +2. **Configure env files**. Copy `node.env.example` to `node1.env`, + `node2.env`, `node3.env`. Paste the three pubkeys from step 1 into + `DCHAIN_VALIDATORS` in ALL THREE files. Set `DOMAIN` to your public host. + +3. **Start the network**: + + ```bash + DOMAIN=dchain.example.com docker compose up -d + docker compose logs -f node1 # watch genesis + first blocks + ``` + + First block is genesis (index 0), created only by `node1` because it has + the `--genesis` flag. After you see blocks #1, #2, #3… committing, + **edit `docker-compose.yml` and remove the `--genesis` flag from node1's + command section**, then `docker compose up -d node1` to re-create it + without that flag. Leaving `--genesis` in makes no-op on a non-empty DB + but is noise in the logs. + +4. **Verify HTTPS** and HTTP-to-HTTPS redirect: + + ```bash + curl -s https://$DOMAIN/api/netstats | jq + curl -s https://$DOMAIN/api/well-known-contracts | jq + ``` + + Caddy should have issued a cert automatically from Let's Encrypt. + +5. **(Optional) observability**: + + ```bash + GRAFANA_ADMIN_PW=$(openssl rand -hex 24) docker compose --profile monitor up -d + # Grafana at http://:3000, user admin, password from env + ``` + + Add a "Prometheus" data source pointing at `http://prometheus:9090`, + then import a dashboard that graphs: + - `dchain_blocks_total` (rate) + - `dchain_tx_submit_accepted_total` / `rejected_total` + - `dchain_ws_connections` + - `dchain_peer_count_live` + - `rate(dchain_block_commit_seconds_sum[5m]) / rate(dchain_block_commit_seconds_count[5m])` + +## Common tasks + +### Add a 4th validator + +The new node joins as an observer via `--join`, then an existing validator +promotes it on-chain: + +```bash +# On the new box +docker run -d --name node4 \ + --volumes chaindata:/data \ + -e DCHAIN_ANNOUNCE=/ip4//tcp/4001 \ + dchain-node-slim \ + --db=/data/chain --join=https://$DOMAIN --register-relay +``` + +Then from any existing validator: + +```bash +docker compose exec node1 /usr/local/bin/client add-validator \ + --key /keys/node.json \ + --node http://localhost:8080 \ + --target +``` + +The new node starts signing as soon as it sees itself in the validator set +on-chain — no restart needed. + +### Upgrade without downtime + +PBFT tolerates `f` faulty nodes out of `3f+1`. For 3 validators that means +**zero** — any offline node halts consensus. So for 3-node clusters: + +1. `docker compose pull && docker compose build` on all three hosts first. +2. Graceful one-at-a-time: `docker compose up -d --no-deps node1`, wait for + `/api/netstats` to show it catching up, then do node2, then node3. + +For 4+ nodes you can afford one-at-a-time hot rolls. + +### Back up the chain + +```bash +docker run --rm -v node1_data:/data -v "$PWD":/bak alpine \ + tar czf /bak/dchain-backup-$(date +%F).tar.gz -C /data . +``` + +Restore by swapping the file back into a fresh named volume before node +startup. + +### Remove a bad validator + +Same as adding but with `remove-validator`. Only works if a majority of +CURRENT validators cosign the removal — intentional, keeps one rogue +validator from kicking others unilaterally (see ROADMAP P2.1). + +## Security notes + +- `/metrics` is firewalled to internal networks by Caddy. If you need + external scraping, add proper auth (Caddy `basicauth` or mTLS). +- All public endpoints are rate-limited per-IP via the node itself — see + `api_guards.go`. Adjust limits before releasing to the open internet. +- Each node runs as non-root inside a read-only rootfs container with all + capabilities dropped. If you need to exec into one, `docker compose exec + --user root nodeN sh`. +- The Ed25519 key files mounted at `/keys/node.json` are your validator + identities. Losing them means losing your ability to produce blocks; get + them onto the host via your normal secret-management (Vault, sealed- + secrets, encrypted tarball at deploy time). **Never commit them to git.** + +## Troubleshooting + +| Symptom | Check | +|---------|-------| +| Caddy keeps issuing `failed to get certificate` | Is port 80 open? DNS A-record pointing here? `docker compose logs caddy` | +| New node can't sync: `FATAL: genesis hash mismatch` | The `--db` volume has data from a different chain. `docker volume rm nodeN_data` and re-up | +| Chain stops producing blocks | `docker compose logs nodeN \| tail -100`; look for `SLOW AddBlock` or validator silence | +| `/api/ws` returns 429 | Client opened > `WSMaxConnectionsPerIP` (default 10). Check `ws.go` for per-IP cap | +| Disk usage growing | Background vlog GC runs every 5 min. Manual: `docker compose exec nodeN /bin/sh -c 'kill -USR1 1'` (see `StartValueLogGC`) | diff --git a/deploy/prod/caddy/Caddyfile b/deploy/prod/caddy/Caddyfile new file mode 100644 index 0000000..1b5a0a5 --- /dev/null +++ b/deploy/prod/caddy/Caddyfile @@ -0,0 +1,88 @@ +# Caddy configuration for DChain prod. +# +# What this does: +# 1. Auto-HTTPS via Let's Encrypt (requires the DOMAIN envvar and +# a DNS A-record pointing at this host). +# 2. Round-robins HTTP /api/* across the three node backends. GETs are +# idempotent so round-robin is safe; POST /api/tx is accepted by any +# validator and gossiped to the rest — no stickiness needed. +# 3. Routes /api/ws (WebSocket upgrade) through with header +# preservation. Uses ip_hash (lb_policy client_ip) so one client +# sticks to one node — avoids re-doing the auth handshake on every +# subscribe. +# 4. Serves /metrics ONLY from localhost IPs so the Prometheus inside +# the stack can scrape it; public scrapers are refused. +# +# To use: +# - Set environment var DOMAIN before `docker compose up`: +# DOMAIN=dchain.example.com docker compose up -d +# - DNS must resolve DOMAIN → this host's public IP. +# - Port 80 must be reachable for ACME HTTP-01 challenge. + +{ + # Global options. `auto_https` is on by default — leave it alone. + email {$ACME_EMAIL:admin@example.com} + servers { + # Enable HTTP/3 for mobile clients. + protocols h1 h2 h3 + } +} + +# ── Public endpoint ──────────────────────────────────────────────────────── +{$DOMAIN:localhost} { + # Compression for JSON / HTML responses. + encode zstd gzip + + # ── WebSocket ────────────────────────────────────────────────────── + # Client-IP stickiness so reconnects land on the same node. This keeps + # per-subscription state local and avoids replaying every auth+subscribe + # to a cold node. + @ws path /api/ws + handle @ws { + reverse_proxy node1:8080 node2:8080 node3:8080 { + lb_policy ip_hash + # Health-check filters dead nodes out of the pool automatically. + health_uri /api/netstats + health_interval 15s + # Upgrade headers preserved by Caddy by default for WS path; no + # extra config needed. + } + } + + # ── REST API ────────────────────────────────────────────────────── + handle /api/* { + reverse_proxy node1:8080 node2:8080 node3:8080 { + lb_policy least_conn + health_uri /api/netstats + health_interval 15s + # Soft fail open: if no node is healthy, return a clear 503. + fail_duration 30s + } + } + + # ── /metrics — internal only ────────────────────────────────────── + # Refuse external scraping of Prometheus metrics. Inside the Docker + # network Prometheus hits node1:8080/metrics directly, bypassing Caddy. + @metricsPublic { + path /metrics + not remote_ip 127.0.0.1 ::1 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8 + } + handle @metricsPublic { + respond "forbidden" 403 + } + + # ── Everything else → explorer HTML ─────────────────────────────── + handle { + reverse_proxy node1:8080 { + health_uri /api/netstats + health_interval 15s + } + } + + # Server-side logging; write JSON for easy log aggregation. + log { + output stdout + format json + level INFO + } +} diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml new file mode 100644 index 0000000..bca03d2 --- /dev/null +++ b/deploy/prod/docker-compose.yml @@ -0,0 +1,175 @@ +name: dchain-prod + +# ══════════════════════════════════════════════════════════════════════════ +# DChain production stack. +# +# Layout: +# - 3 validator nodes, each with its own persistent volume and key file +# - Caddy reverse proxy on the edge: auto-HTTPS from Let's Encrypt, +# rewrites ws upgrades, round-robins /api/* across nodes +# - Prometheus + Grafana for observability (optional, profile=monitor) +# +# Quick start (1-host single-server): +# cp node.env.example node1.env # edit domain / pubkeys +# cp node.env.example node2.env +# cp node.env.example node3.env +# docker compose up -d # runs nodes + Caddy +# docker compose --profile monitor up -d # adds Prometheus + Grafana +# +# For multi-host (the realistic case), copy this file per VPS and remove +# the two nodes that aren't yours; Caddy can still live on one of them or +# on a dedicated edge box. Operators are expected to edit this file — +# it's a reference, not a magic turnkey. +# +# Key files: +# ./keys/node{1,2,3}.json — Ed25519 identity, bake in via bind mount +# ./caddy/Caddyfile — auto-HTTPS config +# ./node.env.example — ENV template +# ./prometheus.yml — scrape config +# ══════════════════════════════════════════════════════════════════════════ + +networks: + internet: + name: dchain_internet + driver: bridge + +volumes: + node1_data: + node2_data: + node3_data: + caddy_data: + caddy_config: + prom_data: + grafana_data: + +x-node-base: &node-base + build: + context: ../.. + dockerfile: deploy/prod/Dockerfile.slim + restart: unless-stopped + networks: [internet] + # Drop all Linux capabilities — the node binary needs none. + cap_drop: [ALL] + # Read-only root FS; only /data is writable (volume-mounted). + read_only: true + tmpfs: [/tmp] + security_opt: [no-new-privileges:true] + # Health check hits /api/netstats through the local HTTP server. + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/netstats >/dev/null || exit 1"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 15s + +services: + node1: + <<: *node-base + container_name: dchain_node1 + hostname: node1 + env_file: ./node1.env + volumes: + - node1_data:/data + - ./keys/node1.json:/keys/node.json:ro + command: + - "--genesis" # drop --genesis after first boot + - "--db=/data/chain" + - "--mailbox-db=/data/mailbox" + - "--key=/keys/node.json" + - "--relay-key=/data/relay.json" + - "--listen=/ip4/0.0.0.0/tcp/4001" + - "--stats-addr=:8080" + - "--heartbeat=true" + - "--register-relay" + + node2: + <<: *node-base + container_name: dchain_node2 + hostname: node2 + env_file: ./node2.env + depends_on: + node1: { condition: service_healthy } + volumes: + - node2_data:/data + - ./keys/node2.json:/keys/node.json:ro + command: + - "--db=/data/chain" + - "--mailbox-db=/data/mailbox" + - "--key=/keys/node.json" + - "--relay-key=/data/relay.json" + - "--listen=/ip4/0.0.0.0/tcp/4001" + - "--stats-addr=:8080" + - "--join=http://node1:8080" # bootstrap from node1 + - "--register-relay" + + node3: + <<: *node-base + container_name: dchain_node3 + hostname: node3 + env_file: ./node3.env + depends_on: + node1: { condition: service_healthy } + volumes: + - node3_data:/data + - ./keys/node3.json:/keys/node.json:ro + command: + - "--db=/data/chain" + - "--mailbox-db=/data/mailbox" + - "--key=/keys/node.json" + - "--relay-key=/data/relay.json" + - "--listen=/ip4/0.0.0.0/tcp/4001" + - "--stats-addr=:8080" + - "--join=http://node1:8080" + - "--register-relay" + + # ── Edge: Caddy with auto-HTTPS + WS upgrade + load-balancing ──────────── + caddy: + image: caddy:2.8-alpine + container_name: dchain_caddy + restart: unless-stopped + networks: [internet] + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 / QUIC + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + node1: { condition: service_healthy } + + # ── Observability ──────────────────────────────────────────────────────── + # Start these only when needed: `docker compose --profile monitor up -d` + + prometheus: + profiles: [monitor] + image: prom/prometheus:v2.53.0 + container_name: dchain_prometheus + restart: unless-stopped + networks: [internet] + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prom_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=30d" + # No external port — exposed only to Grafana via internal network. + + grafana: + profiles: [monitor] + image: grafana/grafana:11.1.0 + container_name: dchain_grafana + restart: unless-stopped + networks: [internet] + ports: + - "3000:3000" + depends_on: [prometheus] + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PW:-change-me} + GF_USERS_ALLOW_SIGN_UP: "false" + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/datasources:/etc/grafana/provisioning/datasources:ro + - ./grafana/dashboards:/etc/grafana/provisioning/dashboards:ro diff --git a/deploy/prod/node.env.example b/deploy/prod/node.env.example new file mode 100644 index 0000000..c1d2c3b --- /dev/null +++ b/deploy/prod/node.env.example @@ -0,0 +1,36 @@ +# DChain node environment — copy to node1.env / node2.env / node3.env and +# customise per-host. These values are read by the node binary via ENV +# fallback (flags still override). +# +# Required: +# DCHAIN_VALIDATORS Comma-separated Ed25519 pubkeys of the initial +# validator set. All three nodes must agree on +# this list at genesis; later additions happen +# on-chain via ADD_VALIDATOR. +# DCHAIN_ANNOUNCE Public libp2p multiaddr peers use to dial this +# node from the internet. e.g. +# /ip4/203.0.113.10/tcp/4001 +# +# Optional: +# DCHAIN_PEERS Bootstrap peer multiaddrs. Auto-filled by +# --join if omitted. +# DCHAIN_GOVERNANCE_CONTRACT Deployed governance contract ID (hex). +# DCHAIN_RELAY_FEE µT per message when registering as a relay. +# ACME_EMAIL Email for Let's Encrypt (TLS expiry reminders). +# DOMAIN Public hostname — Caddy issues cert for this. +# +# Security: +# Key files are bind-mounted at runtime; do NOT put private keys in this +# file. Each node needs its own identity — generate with +# docker compose run --rm node1 /usr/local/bin/client keygen --out /keys/node.json +# and copy out with `docker cp`. + +DCHAIN_VALIDATORS=PUT_FIRST_PUBKEY_HERE,PUT_SECOND_PUBKEY_HERE,PUT_THIRD_PUBKEY_HERE +DCHAIN_ANNOUNCE=/ip4/0.0.0.0/tcp/4001 + +# DCHAIN_PEERS=/ip4/203.0.113.10/tcp/4001/p2p/12D3Koo... +# DCHAIN_GOVERNANCE_CONTRACT= +# DCHAIN_RELAY_FEE=1000 +# ACME_EMAIL=admin@example.com +# DOMAIN=dchain.example.com +# GRAFANA_ADMIN_PW=change-me-to-something-long diff --git a/deploy/prod/prometheus.yml b/deploy/prod/prometheus.yml new file mode 100644 index 0000000..4baa940 --- /dev/null +++ b/deploy/prod/prometheus.yml @@ -0,0 +1,17 @@ +# Prometheus scrape config for DChain prod. +# Mounted read-only into the prometheus container. + +global: + scrape_interval: 15s + scrape_timeout: 5s + evaluation_interval: 30s + external_labels: + network: dchain-prod + +scrape_configs: + - job_name: dchain-node + metrics_path: /metrics + static_configs: + - targets: [node1:8080, node2:8080, node3:8080] + labels: + group: validators diff --git a/deploy/single/Caddyfile b/deploy/single/Caddyfile new file mode 100644 index 0000000..793beda --- /dev/null +++ b/deploy/single/Caddyfile @@ -0,0 +1,46 @@ +# Single-node Caddy: TLS terminate + WS upgrade + internal-only /metrics. +# +# No load balancing — one node backend. Keeps the file short and easy to +# audit. For a multi-node deployment see deploy/prod/caddy/Caddyfile. + +{ + email {$ACME_EMAIL:admin@example.com} + servers { + protocols h1 h2 h3 + } +} + +{$DOMAIN:localhost} { + encode zstd gzip + + # WebSocket (single backend; no stickiness concerns). + @ws path /api/ws + handle @ws { + reverse_proxy node:8080 + } + + # REST API. + handle /api/* { + reverse_proxy node:8080 + } + + # /metrics is for the operator's Prometheus only. Block external IPs. + @metricsPublic { + path /metrics + not remote_ip 127.0.0.1 ::1 172.16.0.0/12 192.168.0.0/16 10.0.0.0/8 + } + handle @metricsPublic { + respond "forbidden" 403 + } + + # Anything else → explorer HTML from the node. + handle { + reverse_proxy node:8080 + } + + log { + output stdout + format json + level INFO + } +} diff --git a/deploy/single/README.md b/deploy/single/README.md new file mode 100644 index 0000000..963eda2 --- /dev/null +++ b/deploy/single/README.md @@ -0,0 +1,387 @@ +# DChain single-node deployment + +Один узел + опционально Caddy TLS + опционально Prometheus/Grafana. +Подходит под четыре основных сценария: + +1. **личная нода** — публичная или приватная с токеном, +2. **первый узел новой сети** (genesis), +3. **присоединение к существующей сети** (relay / observer / validator), +4. **headless API-нода** для мобильных клиентов — без HTML-UI. + +Для 3-валидаторного кластера смотри `../prod/`. + +--- + +## Навигация + +- [0. Что поднимается](#0-что-поднимается) +- [1. Быстрый старт](#1-быстрый-старт) +- [2. Сценарии конфигурации](#2-сценарии-конфигурации) + - [2.1. Публичная нода с UI и открытым Swagger](#21-публичная-нода-с-ui-и-открытым-swagger) + - [2.2. Headless API-нода (без UI, Swagger открыт)](#22-headless-api-нода-без-ui-swagger-открыт) + - [2.3. Полностью приватная (токен на всё, UI выключен)](#23-полностью-приватная-токен-на-всё-ui-выключен) + - [2.4. Только-API без Swagger](#24-только-api-без-swagger) + - [2.5. Первая нода новой сети (--genesis)](#25-первая-нода-новой-сети---genesis) + - [2.6. Присоединение к существующей сети (--join)](#26-присоединение-к-существующей-сети---join) +- [3. HTTP-поверхность](#3-http-поверхность) +- [4. Auto-update от Gitea](#4-auto-update-от-gitea) +- [5. Обновление / бэкап / восстановление](#5-обновление--бэкап--восстановление) +- [6. Troubleshooting](#6-troubleshooting) + +--- + +## 0. Что поднимается + +Базовый compose (`docker compose up -d`) поднимает: + +| Сервис | Что это | Порты | +|--------|---------|-------| +| `node` | сама нода DChain (`dchain-node-slim` image) | `4001` (libp2p P2P, наружу), `8080` (HTTP/WS — только через Caddy) | +| `caddy` | TLS edge с auto-HTTPS (Let's Encrypt) | `80`, `443`, `443/udp` | + +С `--profile monitor` добавляются: + +| Сервис | Что это | Порты | +|--------|---------|-------| +| `prometheus` | метрики + TSDB (30 дней retention) | внутри сети | +| `grafana` | дашборды | `3000` | + +--- + +## 1. Быстрый старт + +```bash +# 1. Сгенерируй ключ ноды (один раз, храни в безопасности). +docker build -t dchain-node-slim -f ../prod/Dockerfile.slim ../.. +mkdir -p keys +docker run --rm --entrypoint /usr/local/bin/client \ + -v "$PWD/keys:/out" dchain-node-slim \ + keygen --out /out/node.json + +# 2. Скопируй env и отредактируй. +cp node.env.example node.env +$EDITOR node.env # минимум: DCHAIN_ANNOUNCE, DOMAIN, DCHAIN_API_TOKEN + +# 3. Подними. +docker compose up -d + +# 4. (опционально) Мониторинг. +GRAFANA_ADMIN_PW=$(openssl rand -hex 16) \ + docker compose --profile monitor up -d +# → Grafana http://:3000, источник http://prometheus:9090 + +# 5. Проверь живость. +curl -s https://$DOMAIN/api/netstats +curl -s https://$DOMAIN/api/well-known-version +``` + +> **Windows:** если запускаете через Docker Desktop и Git Bash, добавляйте +> `MSYS_NO_PATHCONV=1` перед командами с `/out`, `/keys` и подобными Unix-путями +> — иначе Git Bash сконвертирует их в Windows-пути. + +--- + +## 2. Сценарии конфигурации + +Все сценарии отличаются только содержимым `node.env`. Пересоздавать +контейнер: `docker compose up -d --force-recreate node`. + +### 2.1. Публичная нода с UI и открытым Swagger + +**Когда подходит:** вы хотите показать Explorer всем (адрес для поиска +по pubkey, история блоков, список валидаторов), и оставить Swagger как +живую документацию API. + +```ini +# node.env +DCHAIN_ANNOUNCE=/ip4/203.0.113.10/tcp/4001 +DOMAIN=dchain.example.com +ACME_EMAIL=you@example.com + +# никакого токена — публичный режим +# UI и Swagger зажгутся по умолчанию (флаги ниже не задаём) +``` + +Результат: + +| URL | Что там | +|-----|---------| +| `https://$DOMAIN/` | Блок-эксплорер (главная) | +| `https://$DOMAIN/address?pub=…` | Баланс + история по pubkey | +| `https://$DOMAIN/tx?id=…` | Детали транзакции | +| `https://$DOMAIN/validators` | Список валидаторов | +| `https://$DOMAIN/tokens` | Зарегистрированные токены | +| `https://$DOMAIN/swagger` | **Swagger UI** — интерактивная OpenAPI спека | +| `https://$DOMAIN/swagger/openapi.json` | Сырой OpenAPI JSON — для codegen | +| `https://$DOMAIN/api/*` | Вся JSON-API поверхность | +| `https://$DOMAIN/metrics` | Prometheus exposition | + + +### 2.2. Headless API-нода (без UI, Swagger открыт) + +**Когда подходит:** нода — это бэкенд для мобильного приложения, +HTML-эксплорер не нужен, но Swagger хочется оставить как доку для +разработчиков. + +```ini +# node.env +DCHAIN_ANNOUNCE=/ip4/203.0.113.20/tcp/4001 +DOMAIN=api.dchain.example.com + +# Отключаем HTML-страницы эксплорера, но НЕ Swagger. +DCHAIN_DISABLE_UI=true +``` + +Эффект: +- `GET /` → `404 page not found` +- `GET /address`, `/tx`, `/validators`, `/tokens`, `/contract` и все + `/assets/explorer/*` → 404. +- `GET /swagger` → Swagger UI, работает (без изменений). +- `GET /api/*`, `GET /metrics`, `GET /api/ws` → работают. + +Нода логирует: +``` +[NODE] explorer UI: disabled (--disable-ui) +[NODE] swagger: http://0.0.0.0:8080/swagger +``` + + +### 2.3. Полностью приватная (токен на всё, UI выключен) + +**Когда подходит:** персональная нода под мессенджер, вы — единственный +пользователь, никому посторонним не должна быть видна даже статистика. + +```ini +# node.env +DCHAIN_ANNOUNCE=/ip4/203.0.113.30/tcp/4001 +DOMAIN=node.personal.example + +DCHAIN_API_TOKEN=$(openssl rand -hex 32) # скопируйте в клиент +DCHAIN_API_PRIVATE=true # закрывает и read-эндпоинты + +# UI вам не нужен, а кому бы и был — всё равно 401 без токена. +DCHAIN_DISABLE_UI=true +``` + +Эффект: +- Любой `/api/*` без `Authorization: Bearer ` → `401`. +- `/swagger` по-прежнему отдаётся (он не кастомизируется под токены, + а API-вызовы из Swagger UI будут возвращать 401 — это нормально). +- P2P порт `4001` остаётся открытым — без него нода не синкается с сетью. + +Передать токен клиенту: +```ts +// client-app/lib/api.ts — в post()/get() добавить: +headers: { 'Authorization': 'Bearer ' + YOUR_TOKEN } + +// для WebSocket — токен как query-параметр: +this.url = base.replace(/^http/, 'ws') + '/api/ws?token=' + YOUR_TOKEN; +``` + + +### 2.4. Только-API без Swagger + +**Когда подходит:** максимально hardened headless-нода. Даже описание +API поверхности не должно быть на виду. + +```ini +DCHAIN_ANNOUNCE=/ip4/203.0.113.40/tcp/4001 +DOMAIN=rpc.dchain.example.com + +DCHAIN_DISABLE_UI=true +DCHAIN_DISABLE_SWAGGER=true +``` + +Эффект: +- `/` → 404, `/swagger` → 404, `/api/*` → работает. +- В логах: + ``` + [NODE] explorer UI: disabled (--disable-ui) + [NODE] swagger: disabled (--disable-swagger) + ``` +- Swagger спеку всё равно можно сгенерить локально: `go run ./cmd/node` + в dev-режиме → `http://localhost:8080/swagger/openapi.json` → сохранить. + + +### 2.5. Первая нода новой сети (`--genesis`) + +Любой из сценариев выше + установить `DCHAIN_GENESIS=true` при самом +первом запуске. Нода создаст блок 0 со своим же pubkey как единственным +валидатором. После первого успешного старта удалите эту строку из +`node.env` (no-op, но шумит в логах). + +```ini +DCHAIN_GENESIS=true +DCHAIN_ANNOUNCE=/ip4/203.0.113.10/tcp/4001 +DOMAIN=dchain.example.com +``` + +Проверка: +```bash +curl -s https://$DOMAIN/api/netstats | jq .validator_count # → 1 +curl -s https://$DOMAIN/api/network-info | jq .genesis_hash # сохраните +``` + + +### 2.6. Присоединение к существующей сети (`--join`) + +Любой из сценариев + `DCHAIN_JOIN` со списком HTTP URL-ов seed-нод. +Нода подтянет `chain_id`, genesis hash, список валидаторов и пиров +автоматически через `/api/network-info`. Запускается как **observer** +по умолчанию — применяет блоки и принимает tx, но не голосует. + +```ini +DCHAIN_JOIN=https://seed1.dchain.example.com,https://seed2.dchain.example.com +DCHAIN_ANNOUNCE=/ip4/203.0.113.50/tcp/4001 +DOMAIN=node2.example.com +``` + +Чтобы стать валидатором — существующий валидатор должен подать +`ADD_VALIDATOR` с мульти-подписями. См. `../prod/README.md` → +"Add a 4th validator". + +--- + +## 3. HTTP-поверхность + +Что отдаёт нода по умолчанию (все `/api/*` всегда включены, даже с `DCHAIN_DISABLE_UI=true`): + +### Публичные health / discovery +| Endpoint | Назначение | +|----------|-----------| +| `/api/netstats` | tip height, total tx count, supply, validator count | +| `/api/network-info` | one-shot bootstrap payload для нового клиента/ноды | +| `/api/well-known-version` | node_version, protocol_version, features[], build{tag, commit, date, dirty} | +| `/api/well-known-contracts` | канонические contract_id → name map | +| `/api/update-check` | сравнивает свой commit с Gitea release (нужен `DCHAIN_UPDATE_SOURCE_URL`) | +| `/api/validators` | активный validator set | +| `/api/peers` | живые libp2p пиры + их версия (из gossip-топика `dchain/version/v1`) | + +### Chain explorer JSON +| Endpoint | Назначение | +|----------|-----------| +| `/api/blocks?limit=N` | последние N блоков | +| `/api/block/{index}` | один блок | +| `/api/txs/recent?limit=N` | последние N tx | +| `/api/tx/{id}` | одна транзакция | +| `/api/address/{pubkey_or_DC-addr}` | баланс + история | +| `/api/identity/{pubkey_or_DC-addr}` | ed25519 ↔ x25519 binding | +| `/api/relays` | зарегистрированные relay-ноды | +| `/api/contracts` / `/api/contracts/{id}` / `/api/contracts/{id}/state/{key}` | контракты | +| `/api/tokens` / `/api/tokens/{id}` / `/api/nfts` | токены и NFT | +| `/api/channels/{id}` / `/api/channels/{id}/members` | каналы и члены (для fan-out) | + +### Submit / Real-time +| Endpoint | Назначение | +|----------|-----------| +| `POST /api/tx` | submit подписанной tx (rate-limit + body-cap; token-gate если задан) | +| `GET /api/ws` | WebSocket (auth, topic subscribe, submit_tx, typing) | +| `GET /api/events` | SSE (односторонний legacy stream) | + +### HTML (выключается `DCHAIN_DISABLE_UI=true`) +| Endpoint | Назначение | +|----------|-----------| +| `/` | главная эксплорера | +| `/address`, `/tx`, `/node`, `/relays`, `/validators`, `/contract`, `/tokens`, `/token` | страницы | +| `/assets/explorer/*.js|css` | статические ассеты | + +### Swagger (выключается `DCHAIN_DISABLE_SWAGGER=true`) +| Endpoint | Назначение | +|----------|-----------| +| `/swagger` | Swagger UI (грузит swagger-ui-dist с unpkg) | +| `/swagger/openapi.json` | сырая OpenAPI 3.0 спека | + +### Prometheus +| Endpoint | Назначение | +|----------|-----------| +| `/metrics` | exposition, всегда включён | + +> **Защита `/metrics`:** у эндпоинта нет встроенной авторизации. В +> публичной деплое закройте его на уровне Caddy — пример в +> `Caddyfile`: рестрикт по IP/токену scrape-сервера. + +--- + +## 4. Auto-update от Gitea + +После поднятия проекта на Gitea: + +```ini +# node.env +DCHAIN_UPDATE_SOURCE_URL=https://gitea.example.com/api/v1/repos/dchain/dchain/releases/latest +DCHAIN_UPDATE_SOURCE_TOKEN= # опционально, для приватных repo +UPDATE_ALLOW_MAJOR=false # блокирует v1.x → v2.y без явного согласия +``` + +Проверка: +```bash +curl -s https://$DOMAIN/api/update-check | jq . +# { +# "current": { "tag": "v0.5.0", "commit": "abc1234", ... }, +# "latest": { "tag": "v0.5.1", "url": "https://gitea...", ... }, +# "update_available": true, +# "checked_at": "2026-04-17T10:41:03Z" +# } +``` + +systemd-таймер для бесшумного hourly-обновления: +```bash +sudo cp systemd/dchain-update.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now dchain-update.timer +``` + +Скрипт `update.sh`: +1. спрашивает `/api/update-check` — если `update_available: false`, выходит; +2. делает `git fetch --tags`, checkout на новый тег; +3. **semver-guard**: блокирует major-скачок (vN.x → vN+1.y) если + `UPDATE_ALLOW_MAJOR != true`; +4. ребилдит образ с injected версией (`VERSION_TAG/COMMIT/DATE/DIRTY`); +5. smoke-test `node --version`; +6. `docker compose up -d --force-recreate node`; +7. polling `/api/netstats` — до 60 сек, fail loud если не ожил. + +Подробнее — `../UPDATE_STRATEGY.md`. + +--- + +## 5. Обновление / бэкап / восстановление + +```bash +# Ручное обновление (downtime ~5-8 сек): +docker compose pull +docker compose build +docker compose up -d --force-recreate node + +# Проверить что новая версия поднялась: +docker exec dchain_node /usr/local/bin/node --version +curl -s https://$DOMAIN/api/well-known-version | jq .build + +# Backup chain state: +docker run --rm -v dchain-single_node_data:/data -v "$PWD":/bak alpine \ + tar czf /bak/dchain-$(date +%F).tar.gz -C /data . + +# Восстановление: +docker compose stop node +docker run --rm -v dchain-single_node_data:/data -v "$PWD":/bak alpine \ + sh -c "rm -rf /data/* && tar xzf /bak/dchain-2026-04-10.tar.gz -C /data" +docker compose up -d node +``` + +--- + +## 6. Troubleshooting + +| Симптом | Проверка | +|---------|----------| +| `failed to get certificate` в Caddy | DNS A-record на DOMAIN → этот хост? Порт 80 открыт? | +| `/api/tx` возвращает 401 | Токен в заголовке совпадает с `DCHAIN_API_TOKEN`? | +| Ноды не видят друг друга | Порт 4001 открыт? `DCHAIN_ANNOUNCE` = публичный IP? | +| Блоки не растут (validator mode) | `docker compose logs node | grep PBFT` — собирается ли quorum? | +| `/` возвращает 404 | `DCHAIN_DISABLE_UI=true` установлен — либо уберите, либо используйте `/api/*` | +| `/swagger` возвращает 404 | `DCHAIN_DISABLE_SWAGGER=true` — уберите, либо хостьте `openapi.json` отдельно | +| `update-check` возвращает 503 | `DCHAIN_UPDATE_SOURCE_URL` не задан или пустой | +| `update-check` возвращает 502 | Gitea недоступна или URL неверный — проверьте `curl $DCHAIN_UPDATE_SOURCE_URL` руками | +| `FATAL: genesis hash mismatch` | В volume чейн с другим genesis. `docker volume rm dchain-single_node_data` → `up -d` (потеря локальных данных) | +| Диск растёт | BadgerDB GC работает раз в 5 мин; для блокчейна с десятками тысяч блоков обычно < 500 MB | +| `--version` выдаёт `dev` | Образ собран без `--build-arg VERSION_*` — ребилдните через `update.sh` или `docker build --build-arg VERSION_TAG=...` вручную | diff --git a/deploy/single/docker-compose.yml b/deploy/single/docker-compose.yml new file mode 100644 index 0000000..b88ada2 --- /dev/null +++ b/deploy/single/docker-compose.yml @@ -0,0 +1,129 @@ +name: dchain-single + +# ══════════════════════════════════════════════════════════════════════════ +# Single-node DChain deployment. +# +# One validator (or observer) + Caddy TLS edge + optional +# Prometheus/Grafana. Intended for: +# - Personal nodes: operator runs their own, optionally private. +# - Tail of a larger network: joins via --join, participates / observes. +# - First node of a brand-new network: starts with --genesis. +# +# Quick start: +# cp node.env.example node.env # edit DOMAIN / API_TOKEN / JOIN +# docker compose up -d # node + Caddy +# docker compose --profile monitor up -d +# +# For a multi-validator cluster see deploy/prod/ (3-of-3 PBFT setup). +# ══════════════════════════════════════════════════════════════════════════ + +networks: + dchain: + name: dchain_single + driver: bridge + +volumes: + node_data: + caddy_data: + caddy_config: + prom_data: + grafana_data: + +services: + # ── The node ────────────────────────────────────────────────────────── + # One process does everything: consensus (if validator), relay, HTTP, + # WebSocket, metrics. Three knobs are worth knowing before first boot: + # + # 1. DCHAIN_GENESIS=true → creates block 0 with THIS node's key as sole + # validator. Use only once, on the very first node of a fresh chain. + # Drop the flag on subsequent restarts (no-op but noisy). + # 2. DCHAIN_JOIN=http://...,http://... → fetch /api/network-info from + # the listed seeds, auto-populate --peers / --validators, sync chain. + # Use this when joining an existing network instead of --genesis. + # 3. DCHAIN_API_TOKEN=... → if set, gates POST /api/tx (and WS submit). + # With DCHAIN_API_PRIVATE=true, gates reads too. Empty = public. + node: + build: + context: ../.. + dockerfile: deploy/prod/Dockerfile.slim + container_name: dchain_node + restart: unless-stopped + env_file: ./node.env + networks: [dchain] + volumes: + - node_data:/data + - ./keys/node.json:/keys/node.json:ro + # 4001 → libp2p P2P (MUST be publicly routable for federation) + # 8080 → HTTP + WebSocket, only exposed internally to Caddy by default + ports: + - "4001:4001" + expose: + - "8080" + cap_drop: [ALL] + read_only: true + tmpfs: [/tmp] + security_opt: [no-new-privileges:true] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/netstats >/dev/null || exit 1"] + interval: 10s + timeout: 3s + retries: 6 + start_period: 15s + command: + - "--db=/data/chain" + - "--mailbox-db=/data/mailbox" + - "--key=/keys/node.json" + - "--relay-key=/data/relay.json" + - "--listen=/ip4/0.0.0.0/tcp/4001" + - "--stats-addr=:8080" + # All other config comes via DCHAIN_* env vars from node.env. + + # ── TLS edge ────────────────────────────────────────────────────────── + caddy: + image: caddy:2.8-alpine + container_name: dchain_caddy + restart: unless-stopped + networks: [dchain] + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + environment: + DOMAIN: ${DOMAIN:-localhost} + ACME_EMAIL: ${ACME_EMAIL:-admin@example.com} + depends_on: + node: { condition: service_healthy } + + # ── Observability (opt-in) ──────────────────────────────────────────── + prometheus: + profiles: [monitor] + image: prom/prometheus:v2.53.0 + container_name: dchain_prometheus + restart: unless-stopped + networks: [dchain] + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prom_data:/prometheus + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=30d" + + grafana: + profiles: [monitor] + image: grafana/grafana:11.1.0 + container_name: dchain_grafana + restart: unless-stopped + networks: [dchain] + ports: + - "3000:3000" + depends_on: [prometheus] + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PW:-change-me} + GF_USERS_ALLOW_SIGN_UP: "false" + volumes: + - grafana_data:/var/lib/grafana diff --git a/deploy/single/node.env.example b/deploy/single/node.env.example new file mode 100644 index 0000000..d57d95d --- /dev/null +++ b/deploy/single/node.env.example @@ -0,0 +1,119 @@ +# ─────────────────────────────────────────────────────────────────────────── +# Single-node DChain deployment — operator configuration. +# +# Copy this file to `node.env` and fill in the blanks. All variables are +# DCHAIN_*-prefixed; the node binary reads them as flag fallbacks +# (CLI > env > hard-coded default). +# ─────────────────────────────────────────────────────────────────────────── + + +# ══ 1. Mode: first node of a new chain, OR joiner to an existing one ══ + +# Uncomment for the VERY FIRST node of a brand-new network. +# Creates block 0 with this node's key as the sole initial validator. +# Drop this flag after the first successful boot (it's a no-op on a +# non-empty DB but clutters logs). +#DCHAIN_GENESIS=true + +# Comma-separated HTTP URLs of seed nodes to bootstrap from. The node +# fetches /api/network-info from each in order until one replies, then +# auto-populates --peers / --validators and starts syncing. +# +# Leave empty ONLY if you're using --genesis above (first node) OR you're +# running a standalone offline node for testing. +#DCHAIN_JOIN=https://seed1.dchain.example.com,https://seed2.dchain.example.com + + +# ══ 2. Access control ═══════════════════════════════════════════════════ + +# Shared secret required to submit transactions. Without this, ANY client +# that can reach your node can submit txs through it (they still need a +# valid signature, so they can't forge — but they could clutter YOUR +# mempool with their traffic). +# +# Recommended: +# DCHAIN_API_TOKEN=$(openssl rand -hex 32) +# +# Configure the same value in your mobile/desktop client's "Authorization: +# Bearer ..." header. Leave commented-out for a fully public node. +#DCHAIN_API_TOKEN=REPLACE_WITH_A_LONG_RANDOM_SECRET + +# Go a step further: require the token on READ endpoints too. Only you +# (and anyone you share the token with) can query /api/netstats, balances, +# tx history, etc. Useful for a personal node where chat metadata is +# sensitive. Requires DCHAIN_API_TOKEN above to be set. +#DCHAIN_API_PRIVATE=true + + +# ══ 3. Networking ══════════════════════════════════════════════════════ + +# Public libp2p multiaddr others will use to dial this node. Substitute +# your VPS's public IP (or use a hostname resolved via DNS). Port 4001 +# must be open on your firewall. +DCHAIN_ANNOUNCE=/ip4/CHANGE_ME_TO_YOUR_PUBLIC_IP/tcp/4001 + +# Public domain for HTTPS access. Must have a DNS A-record pointing at +# this host BEFORE `docker compose up` — Caddy issues a cert via +# Let's Encrypt on first start. +DOMAIN=node.example.com +ACME_EMAIL=admin@example.com + + +# ══ 4. Role ═══════════════════════════════════════════════════════════ + +# Observer mode: this node applies blocks and serves HTTP/WS but never +# proposes or votes. Use if you want an API-only node (e.g. running behind +# a load balancer for clients, without caring about consensus). Skip if +# this node is a validator. +#DCHAIN_OBSERVER=true + +# Submit a REGISTER_RELAY tx at startup so clients can use this node as a +# relay for encrypted messages. Costs 1 tx fee (1000 µT by default). +# Requires the node identity to have a minimum balance. +#DCHAIN_REGISTER_RELAY=true +#DCHAIN_RELAY_FEE=1000 + +# Governance contract ID — if your network uses on-chain gas-price / +# parameter voting. Auto-discovered from --join seeds; only set manually +# to pin a non-canonical deployment. +#DCHAIN_GOVERNANCE_CONTRACT= + + +# ══ 5. Validator-only ═════════════════════════════════════════════════ + +# Validator set (comma-separated pubkeys). On a joining node this gets +# populated automatically from --join. On --genesis this is the initial +# set (usually just this node's own pubkey). +#DCHAIN_VALIDATORS= + + +# ══ 6. Logging ════════════════════════════════════════════════════════ + +# `text` is human-readable; `json` is machine-parsable for Loki/ELK. +DCHAIN_LOG_FORMAT=json + + +# ══ 7. Auto-update (used by deploy/single/update.sh + systemd timer) ══ + +# Full URL of your Gitea release-API endpoint. Exposed as /api/update-check. +# Format: https:///api/v1/repos///releases/latest +# When set, the update script prefers this over blind git-fetch — less +# upstream traffic, and releases act as a gate (operator publishes a release +# when a version is known-good). +#DCHAIN_UPDATE_SOURCE_URL=https://gitea.example.com/api/v1/repos/dchain/dchain/releases/latest + +# Optional PAT (personal access token) for private repos. Not needed if the +# repo is public. +#DCHAIN_UPDATE_SOURCE_TOKEN= + +# Semver guard: set to "true" to permit auto-update across major versions +# (v1.x → v2.y). Defaults to false — you get a loud error instead of a +# potentially breaking upgrade at 3am. +#UPDATE_ALLOW_MAJOR=false + + +# ══ 8. Monitoring (only used if you run --profile monitor) ════════════ + +# Grafana admin password. Change this if you expose the dashboard +# publicly. +GRAFANA_ADMIN_PW=change-me-to-something-long diff --git a/deploy/single/prometheus.yml b/deploy/single/prometheus.yml new file mode 100644 index 0000000..6c8f64b --- /dev/null +++ b/deploy/single/prometheus.yml @@ -0,0 +1,18 @@ +global: + scrape_interval: 15s + scrape_timeout: 5s + evaluation_interval: 30s + external_labels: + deployment: dchain-single + +scrape_configs: + - job_name: dchain-node + metrics_path: /metrics + # When --api-private is set, the node will reject the scrape. + # Uncomment the bearer_token line below and set it to the same + # value as DCHAIN_API_TOKEN in node.env. + # authorization: + # type: Bearer + # credentials: SAME_AS_DCHAIN_API_TOKEN + static_configs: + - targets: [node:8080] diff --git a/deploy/single/systemd/README.md b/deploy/single/systemd/README.md new file mode 100644 index 0000000..99cfca0 --- /dev/null +++ b/deploy/single/systemd/README.md @@ -0,0 +1,57 @@ +# Systemd units for DChain auto-update + +Two files, one-time setup. + +## Install + +Assumes the repo is checked out at `/opt/dchain`. Adjust `WorkingDirectory=` +and `EnvironmentFile=` in `dchain-update.service` if you put it elsewhere. + +```bash +sudo cp dchain-update.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now dchain-update.timer +``` + +## Verify + +```bash +# When does the timer next fire? +systemctl list-timers dchain-update.timer + +# What did the last run do? +journalctl -u dchain-update.service -n 100 --no-pager + +# Run one update immediately, without waiting for the timer +sudo systemctl start dchain-update.service +``` + +## How it behaves + +- Every hour (± up to 15 min jitter) the timer triggers the service. +- The service runs `update.sh` once, which: + - fetches `origin/main` + - if HEAD didn't move: exits 0, nothing touched + - if HEAD moved: fast-forwards, rebuilds image, smoke-tests the new + binary, restarts the container, polls health +- Downtime per update is ~5-8 seconds (Badger reopen + HTTP listener warm-up). +- Failures write to journal; add `OnFailure=` if you want Pushover/email. + +## Disable auto-update + +If you want to pin a version and review changes manually: + +```bash +sudo systemctl disable --now dchain-update.timer +``` + +You can still invoke `update.sh` by hand when you've reviewed and +fast-forwarded your working tree. + +## Why hourly + jitter + +A whole federation restarting in the same 60-second window would drop PBFT +quorum below 2/3 for that window. With 1-hour cadence and 15-min jitter, the +max probability of two validators being down simultaneously is about +`(15s / 15min)² × N_validators²`, which stays safely below the quorum floor +for any realistic N. diff --git a/deploy/single/systemd/dchain-update.service b/deploy/single/systemd/dchain-update.service new file mode 100644 index 0000000..72e2584 --- /dev/null +++ b/deploy/single/systemd/dchain-update.service @@ -0,0 +1,35 @@ +# DChain single-node pull-and-restart service. +# +# Install: +# sudo cp dchain-update.service dchain-update.timer /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now dchain-update.timer +# +# View runs: +# systemctl list-timers dchain-update.timer +# journalctl -u dchain-update.service -n 200 --no-pager +# +# The timer (sibling file) fires the service; the service runs update.sh +# once per fire, which itself is a no-op when HEAD hasn't moved. + +[Unit] +Description=DChain node: fetch latest, rebuild, rolling restart +Documentation=file:///opt/dchain/deploy/UPDATE_STRATEGY.md +# Don't try to update while Docker is still coming up after a host reboot. +After=docker.service network-online.target +Requires=docker.service + +[Service] +Type=oneshot +# REPO_DIR + COMPOSE_FILE come from the update script's defaults; override +# here with Environment= if you moved the checkout to a non-default path. +WorkingDirectory=/opt/dchain +EnvironmentFile=-/opt/dchain/deploy/single/node.env +ExecStart=/opt/dchain/deploy/single/update.sh + +# Lock down the unit — update.sh only needs git + docker + curl. +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/opt/dchain /var/run/docker.sock +ProtectHome=true diff --git a/deploy/single/systemd/dchain-update.timer b/deploy/single/systemd/dchain-update.timer new file mode 100644 index 0000000..ed8521d --- /dev/null +++ b/deploy/single/systemd/dchain-update.timer @@ -0,0 +1,24 @@ +# Timer for dchain-update.service — fires hourly with a random 15-minute jitter. +# +# Why the jitter: if every operator on the same network runs `OnCalendar=hourly` +# at :00:00, the whole federation restarts its nodes in the same minute and +# PBFT quorum drops below 2/3. With a random delay spread across 15 minutes +# each node updates at a slightly different time, so at any instant the vast +# majority of validators remain live. +# +# Persistent=true means if the machine was asleep/off at fire time, the timer +# catches up on next boot instead of silently skipping. + +[Unit] +Description=Run DChain node update hourly +Requires=dchain-update.service + +[Timer] +OnBootSec=10min +OnUnitActiveSec=1h +RandomizedDelaySec=15min +Persistent=true +Unit=dchain-update.service + +[Install] +WantedBy=timers.target diff --git a/deploy/single/update.sh b/deploy/single/update.sh new file mode 100644 index 0000000..76e7690 --- /dev/null +++ b/deploy/single/update.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# deploy/single/update.sh — pull-and-restart update for a DChain single node. +# +# Modes +# ───── +# 1. RELEASE mode (preferred): DCHAIN_UPDATE_SOURCE_URL is set, node has +# /api/update-check — we trust that endpoint to tell us what's latest, +# then git-checkout the matching tag. +# 2. BRANCH mode (fallback): follow origin/main HEAD. +# +# Both modes share the same safety flow: +# - Fast-forward check (no auto-rewriting local history). +# - Smoke-test the new binary BEFORE killing the running container. +# - Pre-restart best-effort health probe. +# - Poll /api/netstats after restart, fail loud if unhealthy in 60s. +# +# Semver guard +# ──────────── +# If UPDATE_ALLOW_MAJOR=false (default), the script refuses to cross a major +# version boundary (v1.x → v2.y). Operator must flip the env var or bump +# manually, avoiding surprise breaking changes from unattended restarts. +# +# Exit codes: +# 0 — up to date or successfully updated +# 1 — new container didn't become healthy +# 2 — smoke test of new image failed +# 3 — git state issue (fetch failed, not fast-forwardable, etc.) +# 4 — semver guard blocked the update + +set -euo pipefail + +REPO_DIR="${REPO_DIR:-/opt/dchain}" +COMPOSE_FILE="${COMPOSE_FILE:-$REPO_DIR/deploy/single/docker-compose.yml}" +IMAGE_NAME="${IMAGE_NAME:-dchain-node-slim}" +CONTAINER="${CONTAINER:-dchain_node}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/netstats}" +UPDATE_CHECK_URL="${UPDATE_CHECK_URL:-http://127.0.0.1:8080/api/update-check}" +GIT_REMOTE="${GIT_REMOTE:-origin}" +GIT_BRANCH="${GIT_BRANCH:-main}" +UPDATE_ALLOW_MAJOR="${UPDATE_ALLOW_MAJOR:-false}" + +log() { printf '%s %s\n' "$(date -Iseconds)" "$*"; } +die() { log "ERROR: $*"; exit "${2:-1}"; } + +command -v docker >/dev/null || die "docker not on PATH" 3 +command -v git >/dev/null || die "git not on PATH" 3 +command -v curl >/dev/null || die "curl not on PATH" 3 + +cd "$REPO_DIR" || die "cannot cd to REPO_DIR=$REPO_DIR" 3 + +# ── Auth header for /api/update-check + /api/netstats on private nodes ──── +auth_args=() +[[ -n "${DCHAIN_API_TOKEN:-}" ]] && auth_args=(-H "Authorization: Bearer ${DCHAIN_API_TOKEN}") + +# ── 1. Discover target version ──────────────────────────────────────────── +target_tag="" +target_commit="" + +if [[ -n "${DCHAIN_UPDATE_SOURCE_URL:-}" ]]; then + log "querying release source via $UPDATE_CHECK_URL" + check_json=$(curl -fsS -m 10 "${auth_args[@]}" "$UPDATE_CHECK_URL" 2>/dev/null || echo "") + if [[ -n "$check_json" ]]; then + # Minimal grep-based JSON extraction — avoids adding a jq dependency. + # The shape is stable: we defined it in api_update_check.go. + target_tag=$(printf '%s' "$check_json" | grep -o '"tag":"[^"]*"' | head -1 | sed 's/"tag":"\(.*\)"/\1/') + update_available=$(printf '%s' "$check_json" | grep -o '"update_available":\(true\|false\)' | head -1 | cut -d: -f2) + if [[ "$update_available" != "true" ]]; then + log "up to date according to release source ($target_tag) — nothing to do" + exit 0 + fi + log "release source reports new tag: $target_tag" + else + log "release source query failed — falling back to git branch mode" + fi +fi + +# ── 2. Fetch git ────────────────────────────────────────────────────────── +log "fetching $GIT_REMOTE" +git fetch --quiet --tags "$GIT_REMOTE" "$GIT_BRANCH" || die "git fetch failed" 3 + +local_sha=$(git rev-parse HEAD) +if [[ -n "$target_tag" ]]; then + # Release mode: target is the tag we just read from the API. + if ! git rev-parse --verify "refs/tags/$target_tag" >/dev/null 2>&1; then + die "release tag $target_tag unknown to local git — fetch may have missed it" 3 + fi + target_commit=$(git rev-parse "refs/tags/$target_tag^{commit}") +else + # Branch mode: target is remote branch HEAD. + target_commit=$(git rev-parse "$GIT_REMOTE/$GIT_BRANCH") + target_tag=$(git describe --tags --abbrev=0 "$target_commit" 2>/dev/null || echo "$target_commit") +fi + +if [[ "$local_sha" == "$target_commit" ]]; then + log "up to date at $local_sha — nothing to do" + exit 0 +fi +log "updating $local_sha → $target_commit ($target_tag)" + +# ── 3. Semver guard ─────────────────────────────────────────────────────── +# Extract major components from vX.Y.Z tags; refuse to cross boundary unless +# operator opts in. Treats non-semver tags (e.g. raw SHA in branch mode) as +# unversioned — guard is a no-op there. +current_tag=$(cat "$REPO_DIR/.last-update" 2>/dev/null || echo "") +if [[ -z "$current_tag" ]]; then + current_tag=$(git describe --tags --abbrev=0 "$local_sha" 2>/dev/null || echo "") +fi +current_major=$(printf '%s' "$current_tag" | sed -nE 's/^v([0-9]+)\..*/\1/p') +target_major=$(printf '%s' "$target_tag" | sed -nE 's/^v([0-9]+)\..*/\1/p') +if [[ -n "$current_major" && -n "$target_major" && "$current_major" != "$target_major" ]]; then + if [[ "$UPDATE_ALLOW_MAJOR" != "true" ]]; then + die "major version jump $current_tag → $target_tag blocked — set UPDATE_ALLOW_MAJOR=true to override" 4 + fi + log "semver guard: accepting major jump $current_tag → $target_tag (UPDATE_ALLOW_MAJOR=true)" +fi + +# ── 4. Fast-forward / checkout ──────────────────────────────────────────── +if [[ -n "$target_tag" ]] && git rev-parse --verify "refs/tags/$target_tag" >/dev/null 2>&1; then + # Release mode: check out the tag in detached HEAD. + git checkout --quiet "$target_tag" || die "checkout $target_tag failed" 3 +else + git merge --ff-only "$GIT_REMOTE/$GIT_BRANCH" || die "cannot fast-forward — manual merge required" 3 +fi + +# ── 5. Build image with version metadata ────────────────────────────────── +version_tag="$target_tag" +version_commit="$target_commit" +version_date=$(date -u +%Y-%m-%dT%H:%M:%SZ) +version_dirty=$(git diff --quiet HEAD -- 2>/dev/null && echo false || echo true) + +log "building image $IMAGE_NAME:$version_commit ($version_tag)" +docker build --quiet \ + --build-arg "VERSION_TAG=$version_tag" \ + --build-arg "VERSION_COMMIT=$version_commit" \ + --build-arg "VERSION_DATE=$version_date" \ + --build-arg "VERSION_DIRTY=$version_dirty" \ + -t "$IMAGE_NAME:$version_commit" \ + -t "$IMAGE_NAME:latest" \ + -f deploy/prod/Dockerfile.slim . \ + || die "docker build failed" 2 + +# ── 6. Smoke-test the new binary ────────────────────────────────────────── +log "smoke-testing new image" +smoke=$(docker run --rm --entrypoint /usr/local/bin/node "$IMAGE_NAME:$version_commit" --version 2>&1) \ + || die "new image --version failed: $smoke" 2 +log "smoke ok: $smoke" + +# ── 7. Recreate the container ───────────────────────────────────────────── +log "recreating container $CONTAINER" +docker compose -f "$COMPOSE_FILE" up -d --force-recreate node \ + || die "docker compose up failed" 1 + +# ── 8. Wait for health ──────────────────────────────────────────────────── +log "waiting for health at $HEALTH_URL" +for i in $(seq 1 30); do + if curl -fsS -m 3 "${auth_args[@]}" "$HEALTH_URL" >/dev/null 2>&1; then + log "node healthy after $((i*2))s — update done ($version_tag @ ${version_commit:0:8})" + printf '%s\n' "$version_tag" > .last-update + exit 0 + fi + sleep 2 +done + +log "new container did not become healthy in 60s — dumping logs" +docker logs --tail 80 "$CONTAINER" || true +exit 1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a64b381 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,270 @@ +name: dchain + +# ═══════════════════════════════════════════════════════════════════════════ +# DChain — три полноценных ноды, симуляция работы в интернете +# +# Топология сети: +# +# ┌──────────────────────────────────────────────────────────────────────┐ +# │ internet (172.30.0.0/24) │ +# │ │ +# │ node1 ── 172.30.0.11 validator + relay :4001 :8081 │ +# │ node2 ── 172.30.0.12 validator + relay :4002 :8082 │ +# │ node3 ── 172.30.0.13 validator + relay :4003 :8083 │ +# │ │ +# └──────────────────────────────────────────────────────────────────────┘ +# │ │ │ +# dc1 (172.31.1.0/24) dc2 (172.31.2.0/24) dc3 (172.31.3.0/24) +# internal — только internal — только internal — только +# для node1 для node2 для node3 +# +# Правила: +# • Ноды общаются только через backbone «internet» по фиксированным IP. +# • Каждая dc-сеть изолирована (internal: true) — имитирует приватную +# LAN датацентра, недоступную для других нод напрямую. +# • P2P bootstrap — через IP backbone-сети, как в реальном интернете. +# • Все три ноды — полноценные валидаторы и relay-провайдеры. +# PBFT 3-of-3, fault tolerance f=1: сеть работает при падении одной. +# +# Идентичности (ключи вшиты в образ из testdata/): +# node1 pub: 26018d40e40514f38f799eee403f62da98cb5ac936e29049629f1873cbcb4070 +# pid: 12D3KooWCNj2ugnjqoJFPdRuhGZHvGTbEiTMmHDimfsxmGYcjGo9 +# +# node2 pub: bf3628d1a10fcf5a90d2cb31f387c8d1f2dac6a2c54c736c27d5ea04af9696a2 +# pid: 12D3KooWNgmwMbaw5K7vDbGxb9zvcF8gur5GWXGoFEVfKzSNc9bf +# +# node3 pub: 6316e7427654cd2e300033c5e13b6182d595ec2c63bc8396b74183296112510c +# pid: 12D3KooWGVAnaq1EgH1dN49fQWvw1z71R5bZj7UmPkUeQkW4783V +# +# Быстрый старт: +# docker compose up --build -d +# docker compose --profile deploy run --rm deploy +# open http://localhost:8081 +# +# Сброс данных: +# docker compose down -v && docker compose up --build -d +# ═══════════════════════════════════════════════════════════════════════════ + +# ── Сети ───────────────────────────────────────────────────────────────────── + +networks: + + # Общий backbone «интернет» — единственный путь между нодами + internet: + name: dchain_internet + driver: bridge + ipam: + driver: default + config: + - subnet: 172.30.0.0/24 + gateway: 172.30.0.1 + + # Изолированные «датацентры» — нет маршрута до других нод и хоста + dc1: + name: dchain_dc1 + driver: bridge + internal: true + ipam: + config: + - subnet: 172.31.1.0/24 + + dc2: + name: dchain_dc2 + driver: bridge + internal: true + ipam: + config: + - subnet: 172.31.2.0/24 + + dc3: + name: dchain_dc3 + driver: bridge + internal: true + ipam: + config: + - subnet: 172.31.3.0/24 + +# ── Тома ───────────────────────────────────────────────────────────────────── + +volumes: + node1_data: + node2_data: + node3_data: + +# ── Общие якоря ────────────────────────────────────────────────────────────── + +x-node-base: &node-base + build: . + restart: unless-stopped + +# Все три ноды — валидаторы (PBFT 3-of-3) +x-validators: &validators + VALIDATORS: "26018d40e40514f38f799eee403f62da98cb5ac936e29049629f1873cbcb4070,bf3628d1a10fcf5a90d2cb31f387c8d1f2dac6a2c54c736c27d5ea04af9696a2,6316e7427654cd2e300033c5e13b6182d595ec2c63bc8396b74183296112510c" + +x-healthcheck: &healthcheck + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/netstats | grep -q total_blocks"] + interval: 5s + timeout: 3s + retries: 24 + start_period: 8s + +# ════════════════════════════════════════════════════════════════════════════ +services: + + # ── node1 — Лондон / server-1.dchain.local ────────────────────────────── + # Роль : genesis · validator · relay provider + # Backbone: 172.30.0.11 DC LAN: 172.31.1.10 + # Relay fee: 2000 µT + # ──────────────────────────────────────────────────────────────────────── + node1: + <<: *node-base + container_name: node1 + hostname: server-1.dchain.local + networks: + internet: + ipv4_address: 172.30.0.11 + dc1: + ipv4_address: 172.31.1.10 + ports: + - "4001:4001" # libp2p P2P + - "8081:8080" # HTTP Explorer / REST API + volumes: + - node1_data:/data + environment: + <<: *validators + entrypoint: + - /bin/sh + - -c + - | + exec /usr/local/bin/node \ + --genesis \ + --db /data/chain \ + --mailbox-db /data/mailbox \ + --key /keys/node1.json \ + --relay-key /data/relay.json \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --announce /ip4/172.30.0.11/tcp/4001 \ + --stats-addr :8080 \ + --validators "$$VALIDATORS" \ + --heartbeat=true \ + --register-relay \ + --relay-fee 2000 + healthcheck: *healthcheck + + # ── node2 — Нью-Йорк / server-2.dchain.local ──────────────────────────── + # Роль : validator · relay provider + # Backbone: 172.30.0.12 DC LAN: 172.31.2.10 + # Bootstrap → node1 (172.30.0.11) по backbone IP + # Relay fee: 1500 µT + # ──────────────────────────────────────────────────────────────────────── + node2: + <<: *node-base + container_name: node2 + hostname: server-2.dchain.local + networks: + internet: + ipv4_address: 172.30.0.12 + dc2: + ipv4_address: 172.31.2.10 + ports: + - "4002:4001" + - "8082:8080" + volumes: + - node2_data:/data + environment: + <<: *validators + NODE1_PEER: "/ip4/172.30.0.11/tcp/4001/p2p/12D3KooWCNj2ugnjqoJFPdRuhGZHvGTbEiTMmHDimfsxmGYcjGo9" + entrypoint: + - /bin/sh + - -c + - | + exec /usr/local/bin/node \ + --db /data/chain \ + --mailbox-db /data/mailbox \ + --key /keys/node2.json \ + --relay-key /data/relay.json \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --announce /ip4/172.30.0.12/tcp/4001 \ + --stats-addr :8080 \ + --validators "$$VALIDATORS" \ + --peers "$$NODE1_PEER" \ + --heartbeat=true \ + --register-relay \ + --relay-fee 1500 + depends_on: + node1: + condition: service_healthy + healthcheck: *healthcheck + + # ── node3 — Токио / server-3.dchain.local ─────────────────────────────── + # Роль : validator · relay provider + # Backbone: 172.30.0.13 DC LAN: 172.31.3.10 + # Bootstrap → node1 + node2 по backbone IP + # Relay fee: 1000 µT (самый дешёвый) + # ──────────────────────────────────────────────────────────────────────── + node3: + <<: *node-base + container_name: node3 + hostname: server-3.dchain.local + networks: + internet: + ipv4_address: 172.30.0.13 + dc3: + ipv4_address: 172.31.3.10 + ports: + - "4003:4001" + - "8083:8080" + volumes: + - node3_data:/data + environment: + <<: *validators + NODE1_PEER: "/ip4/172.30.0.11/tcp/4001/p2p/12D3KooWCNj2ugnjqoJFPdRuhGZHvGTbEiTMmHDimfsxmGYcjGo9" + NODE2_PEER: "/ip4/172.30.0.12/tcp/4001/p2p/12D3KooWNgmwMbaw5K7vDbGxb9zvcF8gur5GWXGoFEVfKzSNc9bf" + entrypoint: + - /bin/sh + - -c + - | + exec /usr/local/bin/node \ + --db /data/chain \ + --mailbox-db /data/mailbox \ + --key /keys/node3.json \ + --relay-key /data/relay.json \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --announce /ip4/172.30.0.13/tcp/4001 \ + --stats-addr :8080 \ + --validators "$$VALIDATORS" \ + --peers "$$NODE1_PEER,$$NODE2_PEER" \ + --heartbeat=true \ + --register-relay \ + --relay-fee 1000 + depends_on: + node1: + condition: service_healthy + healthcheck: *healthcheck + + # ── deploy — одноразовый деплой 4 production-контрактов ───────────────── + # Запуск: docker compose --profile deploy run --rm deploy + # Ждёт готовности всех трёх нод, затем деплоит контракты. + # ──────────────────────────────────────────────────────────────────────── + deploy: + profiles: [deploy] + build: . + container_name: deploy + restart: "no" + networks: + - internet + volumes: + - ./scripts/deploy_contracts.sh:/scripts/deploy_contracts.sh:ro + entrypoint: ["/bin/sh", "/scripts/deploy_contracts.sh"] + environment: + # Backbone IP-адреса — без DNS, как будто это разные серверы в интернете + NODE1_URL: "http://172.30.0.11:8080" + NODE2_URL: "http://172.30.0.12:8080" + NODE3_URL: "http://172.30.0.13:8080" + depends_on: + node1: + condition: service_healthy + node2: + condition: service_healthy + node3: + condition: service_healthy diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..13f7153 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,45 @@ +# REST API + +DChain-нода предоставляет HTTP API на порту `--stats-addr` (по умолчанию `:8080`). + +## Базовые URL + +| Окружение | URL | +|---------|-----| +| Локально | `http://localhost:8081` | +| Docker node1 | `http://node1:8080` | +| Docker node2 | `http://node2:8080` | +| Docker node3 | `http://node3:8080` | + +## Разделы API + +| Документ | Эндпоинты | +|---------|----------| +| [Chain API](chain.md) | Блоки, транзакции, балансы, адреса, stats | +| [Contracts API](contracts.md) | Деплой, вызов, state, логи | +| [Relay API](relay.md) | Отправка сообщений, inbox, контакты | + +## Формат ошибок + +```json +{"error": "описание ошибки"} +``` + +HTTP-статус: 400 для клиентских ошибок, 500 для серверных. + +## Аутентификация + +REST API не требует аутентификации. Транзакции подписываются на стороне клиента (CLI-командами) и отправляются как подписанные JSON-объекты. API не имеет admin-эндпоинтов требующих токенов. + +## Пример + +```bash +# Статистика сети +curl http://localhost:8081/api/stats + +# Баланс адреса +curl http://localhost:8081/api/balance/03a1b2c3... + +# Последние блоки +curl http://localhost:8081/api/blocks?limit=10 +``` diff --git a/docs/api/chain.md b/docs/api/chain.md new file mode 100644 index 0000000..90dfc13 --- /dev/null +++ b/docs/api/chain.md @@ -0,0 +1,314 @@ +# Chain API + +Эндпоинты для чтения блокчейна: блоки, транзакции, балансы, идентичности, валидаторы. + +## Статистика сети + +### `GET /api/netstats` + +Агрегированная статистика сети. + +```bash +curl http://localhost:8081/api/netstats +``` + +```json +{ + "total_blocks": 1024, + "total_txs": 4821, + "validator_count": 3, + "relay_count": 1, + "total_supply_ut": 10000000000 +} +``` + +--- + +## Блоки + +### `GET /api/blocks?limit=N` + +Последние `N` блоков (по умолчанию 20). + +```bash +curl "http://localhost:8081/api/blocks?limit=10" +``` + +```json +[ + { + "index": 1024, + "hash": "a1b2c3...", + "prev_hash": "...", + "timestamp": 1710000000, + "validator": "03abcd...", + "tx_count": 3, + "total_fees_ut": 15000 + }, + ... +] +``` + +--- + +### `GET /api/block/{index}` + +Детали блока по высоте. + +```bash +curl http://localhost:8081/api/block/1024 +``` + +```json +{ + "index": 1024, + "hash": "a1b2c3...", + "prev_hash": "...", + "timestamp": 1710000000, + "validator": "03abcd...", + "transactions": [ + { + "id": "tx-abc123", + "type": "TRANSFER", + "from": "03...", + "to": "04...", + "amount_ut": 1000000, + "fee_ut": 1000 + } + ] +} +``` + +--- + +## Транзакции + +### `GET /api/txs/recent?limit=N` + +Последние транзакции (по умолчанию 20). + +```bash +curl "http://localhost:8081/api/txs/recent?limit=5" +``` + +--- + +### `GET /api/tx/{txid}` + +Транзакция по ID. + +```bash +curl http://localhost:8081/api/tx/tx-abc123def456 +``` + +```json +{ + "id": "tx-abc123", + "block_index": 1024, + "type": "CALL_CONTRACT", + "from": "03abcd...", + "timestamp": 1710000000, + "payload": { ... }, + "signature": "..." +} +``` + +--- + +### `POST /api/tx` + +Отправить подписанную транзакцию. + +```bash +curl -X POST http://localhost:8081/api/tx \ + -H "Content-Type: application/json" \ + -d '{"type":"TRANSFER","from":"03...","payload":{...},"signature":"..."}' +``` + +```json +{"id": "tx-abc123", "status": "accepted"} +``` + +--- + +## Chain API v2 + +Расширенный API для работы с транзакциями. + +### `GET /v2/chain/transactions/{tx_id}` + +```bash +curl http://localhost:8081/v2/chain/transactions/tx-abc123 +``` + +```json +{ + "id": "tx-abc123", + "block_index": 1024, + "payload": { ... }, + "payload_hex": "...", + "signature_hex": "..." +} +``` + +--- + +### `GET /v2/chain/accounts/{account_id}/transactions` + +Транзакции для аккаунта с пагинацией. + +**Query параметры:** +| Параметр | По умолчанию | Описание | +|---------|------------|---------| +| `limit` | 100 | Максимум (max 1000) | +| `after_block` | — | Только блоки после N | +| `before_block` | — | Только блоки до N | +| `order` | `desc` | `asc` или `desc` | + +```bash +curl "http://localhost:8081/v2/chain/accounts/03abcd.../transactions?limit=20&order=desc" +``` + +--- + +### `POST /v2/chain/transactions/draft` + +Создать черновик транзакции для подписания. + +```bash +curl -X POST http://localhost:8081/v2/chain/transactions/draft \ + -H "Content-Type: application/json" \ + -d '{"from":"03...","to":"04...","amount_ut":1000000}' +``` + +```json +{ + "tx": { ... }, + "sign_bytes_hex": "...", + "sign_bytes_base64": "..." +} +``` + +--- + +### `POST /v2/chain/transactions` + +Отправить подписанную транзакцию (расширенный формат). + +```bash +curl -X POST http://localhost:8081/v2/chain/transactions \ + -H "Content-Type: application/json" \ + -d '{"tx":{...},"signature":"..."}' +``` + +--- + +## Адреса + +### `GET /api/address/{addr}?limit=N&offset=N` + +Информация об адресе (DC-адрес или hex pubkey). + +```bash +curl "http://localhost:8081/api/address/DCabc123?limit=20&offset=0" +``` + +```json +{ + "address": "DCabc123...", + "pub_key": "03abcd...", + "balance_ut": 9500000, + "tx_count": 12, + "transactions": [...], + "has_more": false +} +``` + +--- + +## Идентичности и валидаторы + +### `GET /api/identity/{pubkey|addr}` + +Информация об идентичности. + +```bash +curl http://localhost:8081/api/identity/03abcd... +``` + +```json +{ + "pub_key": "03abcd...", + "address": "DCabc123...", + "nick": "alice", + "x25519_pub": "...", + "registered_at": 100, + "stake_ut": 1000000000 +} +``` + +--- + +### `GET /api/validators` + +Список активных валидаторов. + +```bash +curl http://localhost:8081/api/validators +``` + +```json +[ + {"pub_key": "03...", "stake_ut": 1000000000}, + {"pub_key": "04...", "stake_ut": 1000000000} +] +``` + +--- + +### `GET /api/node/{pubkey|addr}?window=N` + +Информация об узле (статистика, репутация). + +```bash +curl http://localhost:8081/api/node/03abcd... +``` + +--- + +### `GET /api/relays` + +Зарегистрированные relay-провайдеры. + +```bash +curl http://localhost:8081/api/relays +``` + +```json +[ + { + "pub_key": "03...", + "relay_pub": "...", + "fee_ut": 100, + "endpoint": "" + } +] +``` + +--- + +## Live Events + +### `GET /api/events` + +Server-Sent Events поток новых блоков и транзакций. + +```bash +curl -N http://localhost:8081/api/events +``` + +``` +data: {"type":"block","index":1025,"hash":"...","tx_count":2} + +data: {"type":"tx","id":"tx-abc","block":1025,"from":"03..."} +``` diff --git a/docs/api/contracts.md b/docs/api/contracts.md new file mode 100644 index 0000000..5cf9c57 --- /dev/null +++ b/docs/api/contracts.md @@ -0,0 +1,285 @@ +# Contracts API + +REST API для работы с WASM-контрактами: деплой, вызов, чтение state и логов. + +> Деплой и вызов контрактов выполняются через CLI-команды `client deploy-contract` и `client call-contract`, которые формируют и отправляют подписанные транзакции через `POST /api/tx`. Прямой HTTP-деплой не поддерживается. + +## Список контрактов + +### `GET /api/contracts` + +Все задеплоенные контракты. + +```bash +curl http://localhost:8081/api/contracts +``` + +```json +{ + "count": 4, + "contracts": [ + { + "contract_id": "a1b2c3d4e5f60001", + "deployer_pub": "03abcd...", + "deployed_at": 100, + "wasm_size": 1842, + "abi": { + "contract": "governance", + "version": "1.0.0", + "methods": [...] + } + }, + ... + ] +} +``` + +--- + +## Детали контракта + +### `GET /api/contracts/{contractID}` + +```bash +curl http://localhost:8081/api/contracts/a1b2c3d4e5f60001 +``` + +```json +{ + "contract_id": "a1b2c3d4e5f60001", + "deployer_pub": "03abcd...", + "deployed_at": 100, + "wasm_size": 1842, + "abi_json": "{...}" +} +``` + +| Поле | Тип | Описание | +|------|-----|---------| +| `contract_id` | string | 16-символьный hex ID | +| `deployer_pub` | string | Pubkey деплоера | +| `deployed_at` | uint64 | Высота блока деплоя | +| `wasm_size` | int | Размер WASM в байтах | +| `abi_json` | string | JSON ABI контракта | + +--- + +## State контракта + +### `GET /api/contracts/{contractID}/state/{key}` + +Читает значение из state контракта по ключу. + +```bash +# Строковое значение +curl http://localhost:8081/api/contracts/a1b2c3d4e5f60001/state/admin + +# Вложенный ключ +curl "http://localhost:8081/api/contracts/a1b2c3d4e5f60001/state/param:gas_price" + +# Ключ эскроу +curl "http://localhost:8081/api/contracts/.../state/e:deal-001:b" +``` + +```json +{ + "key": "admin", + "value_b64": "MDNhYmNk...", + "value_hex": "30336162636...", + "value_u64": null +} +``` + +| Поле | Тип | Описание | +|------|-----|---------| +| `value_b64` | string | Base64-encoded raw bytes | +| `value_hex` | string | Hex-encoded raw bytes | +| `value_u64` | uint64\|null | Если значение ровно 8 байт big-endian | + +**Примеры чтения state контрактов:** + +```bash +# Governance: live параметр +curl "http://localhost:8081/api/contracts/$GOV_ID/state/param:gas_price" + +# Governance: pending предложение +curl "http://localhost:8081/api/contracts/$GOV_ID/state/prop:gas_price" + +# Username registry: pubkey по имени +curl "http://localhost:8081/api/contracts/$REG_ID/state/name:alice" +# value_hex = hex(pubkey_bytes) → hex.DecodeString(value_hex) = pubkey + +# Escrow: статус сделки +curl "http://localhost:8081/api/contracts/$ESC_ID/state/e:deal-001:x" +# value_hex: 61='a' (active), 64='d' (disputed), 72='r' (released), 66='f' (refunded) +``` + +--- + +## Логи контракта + +### `GET /api/contracts/{contractID}/logs?limit=N` + +Последние `N` лог-записей (по умолчанию 50, максимум 100). + +```bash +curl "http://localhost:8081/api/contracts/a1b2c3d4e5f60001/logs?limit=20" +``` + +```json +{ + "contract_id": "a1b2c3d4e5f60001", + "count": 5, + "logs": [ + { + "tx_id": "tx-abc123", + "block_index": 1024, + "timestamp": 1710000000, + "message": "registered: alice" + }, + { + "tx_id": "tx-def456", + "block_index": 1020, + "timestamp": 1709999800, + "message": "initialized" + } + ] +} +``` + +Логи отсортированы от новейших к старейшим. + +--- + +## Деплой контракта (через CLI) + +```bash +client deploy-contract \ + --key key.json \ + --wasm mycontract.wasm \ + --abi mycontract_abi.json \ + --node http://localhost:8081 +``` + +Успешный деплой печатает: +``` +contract_id: a1b2c3d4e5f60001 +``` + +В Docker: +```bash +docker exec node1 client deploy-contract \ + --key /keys/node1.json \ + --wasm /contracts/mycontract.wasm \ + --abi /contracts/mycontract_abi.json \ + --node http://node1:8080 +``` + +--- + +## Вызов контракта (через CLI) + +```bash +# Без аргументов +client call-contract \ + --contract a1b2c3d4e5f60001 \ + --method increment \ + --gas 5000 \ + --key key.json --node http://localhost:8081 + +# Строковый аргумент +client call-contract \ + --contract $REG_ID \ + --method register \ + --arg alice \ + --gas 30000 \ + --key key.json --node http://localhost:8081 + +# Числовой аргумент (uint64) +client call-contract \ + --contract $ESC_ID \ + --method create \ + --arg "deal-001" \ + --arg "$SELLER_PUB" \ + --arg64 5000000 \ + --gas 30000 \ + --key key.json --node http://localhost:8081 + +# Несколько аргументов в JSON +client call-contract \ + --contract $AUCTION_ID \ + --method create \ + --args '["Rare item #1", 1000000, 100]' \ + --gas 20000 \ + --key key.json --node http://localhost:8081 +``` + +### Флаги call-contract + +| Флаг | Тип | Описание | +|------|-----|---------| +| `--contract` | string | ID контракта (16 hex) | +| `--method` | string | Имя метода | +| `--arg` | string | Добавить строковый аргумент (повторяемый) | +| `--arg64` | uint64 | Добавить числовой аргумент (повторяемый) | +| `--args` | JSON | Массив всех аргументов `["str", 42, ...]` | +| `--gas` | uint64 | Gas лимит | +| `--key` | path | Файл ключа | +| `--node` | URL | Node URL | + +--- + +## Токены и NFT + +### `GET /api/tokens` + +```bash +curl http://localhost:8081/api/tokens +``` + +```json +{"count": 2, "tokens": [{...}]} +``` + +### `GET /api/tokens/{id}` + +```bash +curl http://localhost:8081/api/tokens/abc123 +``` + +### `GET /api/tokens/{id}/balance/{pubkey}` + +```bash +curl http://localhost:8081/api/tokens/abc123/balance/03abcd... +``` + +```json +{ + "token_id": "abc123", + "pub_key": "03abcd...", + "address": "DCabc...", + "balance": 1000 +} +``` + +### `GET /api/nfts` + +```bash +curl http://localhost:8081/api/nfts +``` + +### `GET /api/nfts/{id}` + +```bash +curl http://localhost:8081/api/nfts/nft-abc123 +``` + +### `GET /api/nfts/owner/{pubkey}` + +```bash +curl http://localhost:8081/api/nfts/owner/03abcd... +``` + +```json +{"count": 3, "nfts": [{...}]} +``` diff --git a/docs/api/relay.md b/docs/api/relay.md new file mode 100644 index 0000000..2293c48 --- /dev/null +++ b/docs/api/relay.md @@ -0,0 +1,246 @@ +# Relay API + +REST API для работы с шифрованными сообщениями через relay-сеть. + +Сообщения шифруются E2E с использованием NaCl (X25519 + XSalsa20-Poly1305). Relay хранит зашифрованные конверты и доставляет их получателям. + +## Отправить сообщение + +### `POST /relay/send` + +Зашифровать и отправить сообщение получателю. + +**Request body:** +```json +{ + "recipient_pub": "", + "msg_b64": "" +} +``` + +| Поле | Тип | Описание | +|------|-----|---------| +| `recipient_pub` | string | X25519 public key получателя (hex) | +| `msg_b64` | string | Сообщение в base64 | + +```bash +MSG=$(echo -n "Hello, Bob!" | base64) +curl -X POST http://localhost:8081/relay/send \ + -H "Content-Type: application/json" \ + -d "{\"recipient_pub\":\"$BOB_X25519\",\"msg_b64\":\"$MSG\"}" +``` + +**Response:** +```json +{ + "id": "env-abc123", + "recipient_pub": "...", + "status": "sent" +} +``` + +--- + +## Broadcast конверта + +### `POST /relay/broadcast` + +Опубликовать pre-sealed конверт (для light-клиентов, которые шифруют на своей стороне). + +**Request body:** +```json +{ + "envelope": { + "id": "...", + "recipient_pub": "...", + "sender_pub": "...", + "payload_b64": "...", + "timestamp": 1710000000, + "fee_ut": 100 + } +} +``` + +```bash +curl -X POST http://localhost:8081/relay/broadcast \ + -H "Content-Type: application/json" \ + -d '{"envelope": {...}}' +``` + +**Response:** +```json +{"id": "env-abc123", "status": "broadcast"} +``` + +--- + +## Inbox + +### `GET /relay/inbox?pub=&since=&limit=N` + +Получить сообщения из inbox. + +**Query параметры:** +| Параметр | Обязательный | Описание | +|---------|------------|---------| +| `pub` | Да | X25519 pubkey получателя (hex) | +| `since` | Нет | Unix timestamp — только сообщения новее | +| `limit` | Нет | Максимум (по умолчанию 50) | + +```bash +# Получить все сообщения +curl "http://localhost:8081/relay/inbox?pub=$MY_X25519" + +# Только новые (после timestamp) +curl "http://localhost:8081/relay/inbox?pub=$MY_X25519&since=1710000000&limit=20" +``` + +**Response:** +```json +{ + "pub": "...", + "count": 2, + "has_more": false, + "items": [ + { + "id": "env-abc123", + "sender_pub": "...", + "payload_b64": "...", + "timestamp": 1710000000, + "fee_ut": 100 + } + ] +} +``` + +> `payload_b64` содержит зашифрованное сообщение. Расшифровка выполняется на стороне клиента с помощью X25519 private key. + +--- + +### `GET /relay/inbox/count?pub=` + +Количество сообщений в inbox. + +```bash +curl "http://localhost:8081/relay/inbox/count?pub=$MY_X25519" +``` + +**Response:** +```json +{"pub": "...", "count": 3} +``` + +--- + +### `DELETE /relay/inbox/{envID}?pub=` + +Удалить сообщение из inbox. + +```bash +curl -X DELETE "http://localhost:8081/relay/inbox/env-abc123?pub=$MY_X25519" +``` + +**Response:** +```json +{"id": "env-abc123", "status": "deleted"} +``` + +--- + +## Контакты + +### `GET /relay/contacts?pub=` + +Входящие запросы на контакт. + +> Используйте **ed25519** pubkey (не X25519). + +```bash +curl "http://localhost:8081/relay/contacts?pub=$MY_ED25519" +``` + +**Response:** +```json +{ + "pub": "...", + "count": 1, + "contacts": [ + { + "from_pub": "03abcd...", + "from_nick": "alice", + "intro": "Hi! Let's connect.", + "fee_ut": 1000, + "timestamp": 1710000000 + } + ] +} +``` + +--- + +## CLI команды для relay + +Прямой вызов relay API через CLI: + +```bash +# Отправить сообщение (автоматически ищет X25519 ключ в registry) +client send-msg \ + --to $RECIPIENT_PUB \ + --msg "Hello!" \ + --key key.json \ + --node http://localhost:8081 + +# Отправить через @username +client send-msg \ + --to @alice \ + --msg "Hello Alice!" \ + --registry $REGISTRY_ID \ + --key key.json \ + --node http://localhost:8081 + +# Получить сообщения из inbox +client inbox \ + --key key.json \ + --node http://localhost:8081 \ + --limit 20 + +# Удалить прочитанные +client inbox \ + --key key.json \ + --node http://localhost:8081 \ + --delete + +# Запросить контакт +client request-contact \ + --to $RECIPIENT_PUB \ + --fee 1000 \ + --intro "Hi, I want to connect" \ + --key key.json \ + --node http://localhost:8081 +``` + +--- + +## Архитектура relay + +``` +Отправитель Relay Node Получатель + │ │ │ + │── POST /relay/send ───────────────▶│ │ + │ {recipient_pub, msg_b64} │ Encrypt (NaCl box) │ + │ │ Broadcast via gossipsub │ + │ │◀── gossip ─────────────────▶│ + │ │ │ + │ │ Store in mailbox │ + │ │ │ + │ │◀── GET /relay/inbox?pub=... ─│ + │ │ │ + │ │─── {items:[{payload_b64}]} ▶│ + │ │ │ + │ │ Submit RELAY_PROOF tx │ + │ │ (claim fee from sender) │ +``` + +**Gossipsub топик:** `dchain/relay/v1` + +**Fee:** Relay берёт fee (задаётся при регистрации `--relay-fee`). Sender должен иметь достаточный баланс. Fee списывается при доставке через RELAY_PROOF транзакцию. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..991d950 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,207 @@ +# Архитектура DChain + +## Обзор + +DChain — это L1-блокчейн для децентрализованного мессенджера. Архитектура разделена на четыре слоя: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ L3 Application — messaging, usernames, auctions, escrow │ +│ (Smart contracts: username_registry, governance, auction, │ +│ escrow; deployed on-chain via DEPLOY_CONTRACT) │ +├─────────────────────────────────────────────────────────────┤ +│ L2 Transport — relay mailbox, E2E NaCl encryption │ +│ (relay/, mailbox, GossipSub envelopes, RELAY_PROOF tx) │ +├─────────────────────────────────────────────────────────────┤ +│ L1 Chain — PBFT consensus, WASM VM, BadgerDB │ +│ (blockchain/, consensus/, vm/, identity/) │ +├─────────────────────────────────────────────────────────────┤ +│ L0 Network — libp2p, GossipSub, DHT, mDNS │ +│ (p2p/) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Консенсус (PBFT) + +**Алгоритм:** Practical Byzantine Fault Tolerance, кворум 2/3. + +**Фазы:** +``` +Leader Validator-2 Validator-3 + │── PRE-PREPARE ──▶│ │ + │── PRE-PREPARE ────────────────────────▶ │ + │◀─ PREPARE ───────│ │ + │◀─ PREPARE ────────────────────────────── │ + │── COMMIT ────────▶│ │ + │── COMMIT ──────────────────────────────▶ │ + │◀─ COMMIT ────────│ │ + │◀─ COMMIT ────────────────────────────── │ + AddBlock() +``` + +**Свойства:** +- Safety при ≤ f Byzantine нодах где N ≥ 3f+1 +- Текущий testnet: N=2 валидатора, f=0 (узел node3 — relay-only observer) +- View-change при недоступном лидере: таймаут 10 секунд + +**Блок-производство:** +- Fast ticker (500 ms) — при наличии транзакций в mempool +- Idle ticker (5 s) — пустые блоки для heartbeat и синхронизации + +**Ключевые файлы:** +``` +consensus/engine.go — PBFT engine +consensus/msg.go — ConsensusMsg типы (PRE-PREPARE, PREPARE, COMMIT, VIEW-CHANGE) +``` + +--- + +## Хранилище (BadgerDB) + +Весь state хранится в BadgerDB (LSM-дерево, pure Go). + +**Пространства ключей:** + +| Префикс | Тип | Описание | +|---------|-----|---------| +| `block:` | JSON | Блоки по индексу | +| `height` | uint64 JSON | Текущая высота | +| `balance:` | uint64 JSON | Балансы токенов | +| `id:` | JSON | Identity (RegisterKey payload) | +| `validator:` | presence | Активный сет валидаторов | +| `relay:` | JSON | Зарегистрированные relay-ноды | +| `contract:` | JSON | ContractRecord (метаданные + WASM) | +| `cstate::` | raw bytes | Состояние контракта | +| `clog:::` | JSON | Логи контракта | +| `rep:` | JSON | Репутация (blocks, relays, slashes) | +| `contact_in::` | JSON | Входящие contact requests | +| `mail:::` | JSON | Relay mailbox (TTL 7 дней) | + +**Две отдельные БД:** +- `--db ./chaindata` — chain state (consensus state machine) +- `--mailbox-db ./mailboxdata` — relay mailbox (отдельная изоляция) + +--- + +## P2P сеть (libp2p) + +**Компоненты:** +- **Transport:** TCP `/ip4/0.0.0.0/tcp/4001` +- **Identity:** Ed25519 peer key (вывод из node identity) +- **Discovery:** mDNS (локалка) + Kademlia DHT (WAN) +- **Pub/Sub:** GossipSub с тремя топиками: + +| Топик | Содержимое | +|-------|-----------| +| `dchain/tx/v1` | Транзакции (gossip) | +| `dchain/blocks/v1` | Готовые блоки (gossip от лидера) | +| `dchain/relay/v1` | Relay envelopes (зашифрованные сообщения) | + +- **Direct streams:** PBFT consensus messages (pre-prepare/prepare/commit/view-change) идут напрямую между валидаторами через `/dchain/consensus/1.0.0` протокол +- **Sync:** block range sync по `/dchain/sync/1.0.0` при подключении нового пира + +--- + +## WASM Virtual Machine + +**Runtime:** wazero v1.7.3, interpreter mode (детерминированный на всех платформах). + +**Жизненный цикл контракта:** + +``` +DEPLOY_CONTRACT tx + ├── validate: wazero.CompileModule() — если ошибка, tx отклоняется + ├── contractID = hex(sha256(deployerPubKey || wasmBytes))[:16] + ├── BadgerDB: contract: → ContractRecord{WASMBytes, ABI, ...} + └── state: cstate::* — изначально пусто + +CALL_CONTRACT tx + ├── ABI validation: метод существует, число аргументов совпадает + ├── pre-charge: tx.Fee + gasLimit × gasPrice + ├── vm.Call(contractID, wasmBytes, method, argsJSON, gasLimit, hostEnv) + │ ├── compile (cached) + instrument (gas_tick в loop headers) + │ ├── register "env" host module (14 функций) + │ ├── [optional] wasi_snapshot_preview1 (для TinyGo контрактов) + │ └── fn.Call(ctx) → gasUsed, error + ├── refund: (gasLimit - gasUsed) × gasPrice → обратно sender + └── logs: clog::: → BadgerDB +``` + +**Gas модель:** каждый вызов функции (WASM или host) = 100 gas units. +`gasCost (µT) = gasUsed × gasPrice` (gasPrice управляется governance или константа 1 µT). + +**Типы контрактов:** +- **Binary WASM** — написаны на Go через кодогенераторы (`contracts/*/gen/main.go`) +- **TinyGo WASM** — написаны на Go, компилируются с `tinygo -target wasip1` + +--- + +## Экономика + +**Supply:** Фиксированный. Genesis-блок минтит **21 000 000 T** на ключ node1. Последующей эмиссии нет. + +**Unit:** µT (микро-токен). 1 T = 1 000 000 µT. + +**Доходы валидаторов:** только комиссии из транзакций блока (`TotalFees`). Без блок-реварда. + +**Комиссии:** +| Операция | Минимальная fee | +|---------|----------------| +| Все транзакции | 1 000 µT (MinFee) | +| CONTACT_REQUEST | tx.Amount → recipient (anti-spam) | +| DEPLOY_CONTRACT | 10 000 µT | +| CALL_CONTRACT | MinFee + gasUsed × gasPrice | +| RELAY_PROOF | sender → relay node (произвольно) | + +**Governance:** gas_price, relay_fee и другие параметры можно менять on-chain через governance-контракт без хардфорка. + +--- + +## Relay (E2E мессенджер) + +**Шифрование:** NaCl box (X25519 Diffie-Hellman + XSalsa20 + Poly1305). + +**Ключи:** каждый Identity имеет два ключа: +- Ed25519 (подпись транзакций, chain identity) +- X25519 (вывод из Ed25519 seed, шифрование сообщений) + +**Поток сообщения:** +``` +Alice node1 (relay) Bob + │ │ │ + │── seal(msg, bob.X25519) ──▶ │ │ + │ POST /relay/broadcast │ │ + │ │── gossip envelope ──▶ │ + │ │ store mailbox │ + │ │ (TTL 7 days) │ + │ │ │ + │ │◀── GET /relay/inbox ──│ + │ │ │── open(envelope) + │ │ │ → plaintext +``` + +**Anti-spam:** +- Первый контакт — платный (CONTACT_REQUEST tx, fee идёт получателю) +- Envelope: max 64 KB, max 500 envelopes на получателя (FIFO) +- RELAY_PROOF: подписанное доказательство доставки, fee снимается со sender и кредитуется relay + +--- + +## Динамические валидаторы + +Сет валидаторов хранится on-chain в `validator:`. Любой текущий валидатор может добавить или убрать другого (ADD_VALIDATOR / REMOVE_VALIDATOR tx). После commit такого блока PBFT-движок перезагружает сет без рестарта ноды. + +**Ключевые файлы:** +``` +blockchain/chain.go — state machine, applyTx, VMHostEnv +consensus/engine.go — PBFT, UpdateValidators() +vm/vm.go — wazero runtime, NewVM() +vm/host.go — host module "env" (14 функций) +vm/gas.go — gas counter, Remaining() +relay/mailbox.go — BadgerDB TTL mailbox +relay/crypto.go — NaCl seal/open +p2p/host.go — libp2p host, GossipSub +node/api_routes.go — HTTP API routing +``` diff --git a/docs/cli/README.md b/docs/cli/README.md new file mode 100644 index 0000000..1ce3fa0 --- /dev/null +++ b/docs/cli/README.md @@ -0,0 +1,411 @@ +# CLI Reference + +Справочник по командам `client` — инструмента для работы с DChain. + +## Установка + +```bash +go build -o client ./cmd/client/ +# или +go install ./cmd/client/ +``` + +## Глобальные флаги + +Большинство команд принимают: + +| Флаг | Описание | +|------|---------| +| `--key ` | Файл ключа (JSON с Ed25519 + X25519) | +| `--node ` | URL ноды (например `http://localhost:8081`) | + +--- + +## Ключи и идентичность + +### `keygen` + +Сгенерировать новый ключ. + +```bash +client keygen --out alice.json +``` + +Создаёт `alice.json` с Ed25519 (подпись транзакций) и X25519 (E2E relay шифрование). + +--- + +### `register` + +Зарегистрировать идентичность в блокчейне. + +```bash +client register \ + --key alice.json \ + --nick alice \ + --node http://localhost:8081 +``` + +Выполняет PoW, затем отправляет `REGISTER_KEY` транзакцию. + +--- + +## Токены (нативные) + +### `balance` + +```bash +client balance \ + --key alice.json \ + --node http://localhost:8081 +``` + +``` +Balance: 9.5 T (9500000 µT) +``` + +--- + +### `transfer` + +```bash +# По pubkey +client transfer \ + --to 03abcd... \ + --amount 1.5 \ + --key alice.json \ + --node http://localhost:8081 + +# По DC-адресу +client transfer \ + --to DCabc123... \ + --amount 0.5 \ + --key alice.json \ + --node http://localhost:8081 + +# По @username (через registry) +client transfer \ + --to @bob \ + --amount 1.0 \ + --registry $REG_ID \ + --key alice.json \ + --node http://localhost:8081 +``` + +`--amount` в T (токенах). 1 T = 1 000 000 µT. + +--- + +## Смарт-контракты + +### `deploy-contract` + +```bash +client deploy-contract \ + --key alice.json \ + --wasm mycontract.wasm \ + --abi mycontract_abi.json \ + --node http://localhost:8081 +``` + +Выводит `contract_id: `. + +--- + +### `call-contract` + +```bash +# Без аргументов +client call-contract \ + --contract $CONTRACT_ID \ + --method increment \ + --gas 5000 \ + --key alice.json --node http://localhost:8081 + +# Строковые аргументы +client call-contract \ + --contract $REG_ID \ + --method register \ + --arg alice \ + --gas 30000 \ + --key alice.json --node http://localhost:8081 + +# Смешанные аргументы (строка + uint64) +client call-contract \ + --contract $ESC_ID \ + --method create \ + --arg "deal-001" \ + --arg "$SELLER_PUB" \ + --arg64 5000000 \ + --gas 30000 \ + --key alice.json --node http://localhost:8081 + +# JSON-массив аргументов +client call-contract \ + --contract $AUC_ID \ + --method create \ + --args '["Rare item", 1000000, 100]' \ + --gas 20000 \ + --key alice.json --node http://localhost:8081 +``` + +| Флаг | Описание | +|------|---------| +| `--contract` | ID контракта | +| `--method` | Имя метода | +| `--arg` | Строковый аргумент (повторяемый) | +| `--arg64` | uint64 аргумент (повторяемый) | +| `--args` | JSON-массив всех аргументов | +| `--gas` | Gas лимит | + +--- + +## Relay / Сообщения + +### `send-msg` + +```bash +# По pubkey +client send-msg \ + --to $RECIPIENT_PUB \ + --msg "Hello!" \ + --key alice.json --node http://localhost:8081 + +# По @username +client send-msg \ + --to @bob \ + --msg "Hey Bob!" \ + --registry $REG_ID \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `inbox` + +```bash +# Читать сообщения +client inbox \ + --key alice.json \ + --node http://localhost:8081 + +# Последние N +client inbox \ + --key alice.json \ + --limit 20 \ + --node http://localhost:8081 + +# Читать и удалять +client inbox \ + --key alice.json \ + --delete \ + --node http://localhost:8081 +``` + +--- + +### `request-contact` + +```bash +client request-contact \ + --to $RECIPIENT_PUB \ + --fee 1000 \ + --intro "Hi, let's connect!" \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `accept-contact` / `block-contact` + +```bash +client accept-contact \ + --from $SENDER_PUB \ + --key alice.json --node http://localhost:8081 + +client block-contact \ + --from $SENDER_PUB \ + --key alice.json --node http://localhost:8081 +``` + +--- + +## Валидаторы + +### `stake` + +```bash +client stake \ + --amount 1000 \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `unstake` + +```bash +client unstake \ + --key alice.json --node http://localhost:8081 +``` + +--- + +## Утилиты + +### `info` + +```bash +client info --node http://localhost:8081 +``` + +Показывает высоту блока, количество валидаторов, total supply. + +--- + +### `wait-tx` + +Ждать подтверждения транзакции (через SSE). + +```bash +client wait-tx \ + --id tx-abc123 \ + --timeout 30 \ + --node http://localhost:8081 +``` + +--- + +## Токены (fungible) + +### `issue-token` + +```bash +client issue-token \ + --name "MyToken" \ + --symbol MTK \ + --decimals 6 \ + --supply 1000000 \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `transfer-token` + +```bash +client transfer-token \ + --token $TOKEN_ID \ + --to $RECIPIENT_PUB \ + --amount 100 \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `burn-token` + +```bash +client burn-token \ + --token $TOKEN_ID \ + --amount 50 \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `token-balance` + +```bash +client token-balance \ + --token $TOKEN_ID \ + --key alice.json --node http://localhost:8081 + +# Другой адрес +client token-balance \ + --token $TOKEN_ID \ + --address $BOB_PUB \ + --node http://localhost:8081 +``` + +--- + +## NFT + +### `mint-nft` + +```bash +client mint-nft \ + --name "CryptoArt #1" \ + --desc "First edition" \ + --uri "https://example.com/nft/1" \ + --attrs '{"rarity":"rare","color":"blue"}' \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `transfer-nft` + +```bash +client transfer-nft \ + --nft $NFT_ID \ + --to $BOB_PUB \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `burn-nft` + +```bash +client burn-nft \ + --nft $NFT_ID \ + --key alice.json --node http://localhost:8081 +``` + +--- + +### `nft-info` + +```bash +client nft-info \ + --nft $NFT_ID \ + --node http://localhost:8081 + +# Проверить владельца +client nft-info \ + --nft $NFT_ID \ + --owner $ALICE_PUB \ + --node http://localhost:8081 +``` + +--- + +## @Username resolution + +Команды `transfer` и `send-msg` поддерживают `@username` синтаксис при наличии `--registry`: + +```bash +# Resolve @alice → pubkey через username_registry контракт +client transfer \ + --to @alice \ + --amount 1.0 \ + --registry $REGISTRY_ID \ + --key key.json --node http://localhost:8081 +``` + +Под капотом: `GET /api/contracts//state/name:alice` → `value_hex` → `hex.DecodeString` → pubkey. + +--- + +## Переменные окружения + +Для удобства можно задать в `.env` или shell: + +```bash +export NODE_URL=http://localhost:8081 +export MY_KEY=./keys/alice.json +export GOV_ID=a1b2c3d4e5f60001 +export REG_ID=b2c3d4e5f6000002 +``` diff --git a/docs/contracts/README.md b/docs/contracts/README.md new file mode 100644 index 0000000..a605071 --- /dev/null +++ b/docs/contracts/README.md @@ -0,0 +1,59 @@ +# Production Smart Contracts + +DChain поставляется с четырьмя production-контрактами, задеплоенными из genesis-кошелька. + +## Обзор + +| Контракт | Назначение | Ключевые фичи | +|---------|-----------|--------------| +| [username_registry](username_registry.md) | Username ↔ адрес | Тарифная сетка, treasury fees, reverse lookup | +| [governance](governance.md) | On-chain параметры | Propose/approve workflow, admin role | +| [auction](auction.md) | English auction | Token escrow, автоматический refund, settle | +| [escrow](escrow.md) | Двусторонний escrow | Dispute/resolve, admin arbitration | + +## Деплой + +```bash +docker compose --profile deploy run --rm deploy +``` + +Все 4 контракта деплоятся автоматически. ID сохраняются в `/tmp/contracts.env`. + +## Вызов контракта + +```bash +docker exec node1 client call-contract \ + --key /keys/node1.json \ + --contract \ + --method \ + --arg # строковый аргумент (можно несколько) + --arg64 # числовой аргумент uint64 + --gas # рекомендуется 20000 для записи, 5000 для чтения + --node http://node1:8080 +``` + +## Contract Treasury + +У каждого контракта есть **ownerless treasury address** — `hex(sha256(contractID + ":treasury"))`. +Это эскроу-адрес без private key. Только сам контракт может снять с него деньги через host function `transfer`. + +Используется в `auction` и `escrow` для хранения заблокированных токенов. + +## Просмотр состояния + +```bash +# Через REST API +curl http://localhost:8081/api/contracts//state/ + +# Через Explorer +open http://localhost:8081/contract?id= +``` + +## Логи контракта + +```bash +# REST +curl "http://localhost:8081/api/contracts//logs?limit=20" + +# Explorer → вкладка Logs +``` diff --git a/docs/contracts/auction.md b/docs/contracts/auction.md new file mode 100644 index 0000000..92b2f99 --- /dev/null +++ b/docs/contracts/auction.md @@ -0,0 +1,213 @@ +# auction + +English auction с on-chain token escrow. Ставки хранятся в contract treasury. При перебивании ставки предыдущий топ-биддер получает автоматический refund. + +## Жизненный цикл + +``` +seller buyer-1 buyer-2 anyone + │ │ │ │ + │─ create ──────▶│ │ │ + │ (min_bid, │ │ │ + │ duration) │ │ │ + │ │ │ │ + │ │─ bid(500) ───▶│ │ + │ │ treasury ←500│ │ + │ │ │ │ + │ │ │─ bid(800) ───▶│ + │ │ refund→500 │ treasury ←800│ + │ │ │ │ + │ │ │ │ [end_block reached] + │ │ │ │ + │◀─────────────────────────────────── settle ────│ + │ treasury→800 │ │ │ + │ (seller gets │ │ │ + │ winning bid) │ │ │ +``` + +**Статусы:** `open` → `settled` / `cancelled` + +## Auction ID + +Формат: `:`, например `42:0`, `42:1`. +Генерируется автоматически при `create`, логируется как `created: `. + +## Методы + +### `create` + +Создать новый аукцион. + +**Аргументы:** +| # | Имя | Тип | Описание | +|---|-----|-----|---------| +| 0 | `title` | string | Описание лота (max 128 байт) | +| 1 | `min_bid` | uint64 | Минимальная ставка в µT | +| 2 | `duration` | uint64 | Длительность в блоках | + +**Поведение:** +- Сохраняет seller = caller +- `end_block = current_block + duration` +- Лог: `created: ` + +```bash +# Аукцион: мин. ставка 1 T, длительность 100 блоков +client call-contract --method create \ + --arg "Rare NFT #42" --arg64 1000000 --arg64 100 \ + --contract $AUC_ID --key /keys/node1.json \ + --gas 20000 --node http://node1:8080 +``` + +--- + +### `bid` + +Поставить ставку. Средства переводятся из кошелька caller в treasury контракта. + +**Аргументы:** +| # | Имя | Тип | Описание | +|---|-----|-----|---------| +| 0 | `auction_id` | string | ID аукциона | +| 1 | `amount` | uint64 | Ставка в µT | + +**Проверки:** +- Аукцион в статусе `open` +- `current_block ≤ end_block` +- `amount > current_top_bid` (или `amount ≥ min_bid` если ставок нет) + +**Поведение:** +- Переводит `amount` с caller → treasury +- Если был предыдущий топ-биддер → refund ему его ставки из treasury +- Обновляет `top_bidder` и `top_bid` +- Лог: `bid: ` + +```bash +client call-contract --method bid \ + --arg "42:0" --arg64 2000000 \ + --contract $AUC_ID --key /keys/node1.json \ + --gas 30000 --node http://node1:8080 +``` + +--- + +### `settle` + +Завершить аукцион после истечения `end_block`. Переводит топ-ставку продавцу. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `auction_id` | string | + +**Проверки:** `current_block > end_block`, статус `open` + +**Поведение:** +- Если есть ставки: переводит `top_bid` из treasury → seller, статус → `settled` +- Если ставок нет: статус → `cancelled` +- Лог: `settled: ` или `cancelled: (no bids)` +- Может вызвать **любой** (не только seller) + +```bash +client call-contract --method settle --arg "42:0" \ + --contract $AUC_ID --key /keys/node1.json \ + --gas 20000 --node http://node1:8080 +``` + +--- + +### `cancel` + +Отменить аукцион без ставок. Только seller. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `auction_id` | string | + +**Проверки:** статус `open`, ставок нет, `caller == seller` + +```bash +client call-contract --method cancel --arg "42:0" \ + --contract $AUC_ID --key /keys/node1.json \ + --gas 10000 --node http://node1:8080 +``` + +--- + +### `info` + +Запросить состояние аукциона. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `auction_id` | string | + +**Логи:** +``` +seller: +title: +top_bid: <amount> +end_block: <block> +status: open|settled|cancelled +``` + +```bash +client call-contract --method info --arg "42:0" \ + --contract $AUC_ID --key /keys/node1.json \ + --gas 5000 --node http://node1:8080 +``` + +--- + +## State Layout + +``` +cstate:<contractID>:seq → uint64 (глобальный счётчик аукционов) +cstate:<contractID>:a:<id>:s → seller pubkey +cstate:<contractID>:a:<id>:t → title +cstate:<contractID>:a:<id>:b → top_bid (uint64 big-endian) +cstate:<contractID>:a:<id>:e → end_block (uint64 big-endian) +cstate:<contractID>:a:<id>:w → top_bidder pubkey +cstate:<contractID>:a:<id>:x → status byte ('o', 's', 'c') +``` + +## Полный пример сценария + +```bash +# 1. Alice создаёт аукцион +docker exec node1 client call-contract \ + --key /keys/node1.json --contract $AUC_ID \ + --method create \ + --arg "Special Edition #1" --arg64 500000 --arg64 50 \ + --gas 20000 --node http://node1:8080 +# Лог: created: 5:0 + +AUC_ITEM="5:0" + +# 2. Bob делает ставку 1 T +docker exec node1 client call-contract \ + --key /tmp/bob.json --contract $AUC_ID \ + --method bid --arg $AUC_ITEM --arg64 1000000 \ + --gas 30000 --node http://node1:8080 + +# 3. Charlie перебивает ставку — Bob получает refund автоматически +docker exec node1 client call-contract \ + --key /tmp/charlie.json --contract $AUC_ID \ + --method bid --arg $AUC_ITEM --arg64 1500000 \ + --gas 30000 --node http://node1:8080 + +# 4. Проверить статус +docker exec node1 client call-contract \ + --key /keys/node1.json --contract $AUC_ID \ + --method info --arg $AUC_ITEM \ + --gas 5000 --node http://node1:8080 + +# 5. После end_block — завершить (любой может) +docker exec node1 client call-contract \ + --key /keys/node1.json --contract $AUC_ID \ + --method settle --arg $AUC_ITEM \ + --gas 20000 --node http://node1:8080 +# Лог: settled: 5:0 +# Alice получает 1.5 T на кошелёк +``` diff --git a/docs/contracts/escrow.md b/docs/contracts/escrow.md new file mode 100644 index 0000000..512c871 --- /dev/null +++ b/docs/contracts/escrow.md @@ -0,0 +1,267 @@ +# escrow + +Двусторонний trustless escrow. Buyer блокирует средства в contract treasury. Seller выполняет условие. Buyer подтверждает или открывает спор. При споре admin-арбитр решает исход. + +## Жизненный цикл + +``` +buyer seller admin + │ │ │ + │─ create ─────▶│ │ treasury ← amount (locked) + │ (seller, │ │ status: active + │ amount) │ │ + │ │ │ + │ [seller delivered] │ + │ │ │ + │─ release ────▶│ │ treasury → seller + │ │ │ status: released + + OR: + + │─ dispute ────▶│ │ status: disputed + │ │─ dispute ────▶│ (either party can) + │ │ │ + │ │ │─ resolve(winner=buyer) ──▶ + │ │ │ treasury → buyer + │ │ │ status: refunded + │ │ │ + │ │ │─ resolve(winner=seller) ──▶ + │ │ │ treasury → seller + │ │ │ status: released + + OR: + + │ │─ refund ─────▶│ treasury → buyer (voluntary) + │ │ │ status: refunded +``` + +**Статусы:** `active` → `released` / `refunded` / `disputed` + +## Методы + +### `init` + +Установить caller как admin (арбитр споров). + +**Аргументы:** нет + +Вызывается один раз после деплоя (deploy-скрипт делает автоматически). + +```bash +client call-contract --method init \ + --contract $ESC_ID --key /keys/node1.json \ + --gas 5000 --node http://node1:8080 +``` + +--- + +### `create` + +Buyer создаёт escrow и блокирует средства. + +**Аргументы:** +| # | Имя | Тип | Описание | +|---|-----|-----|---------| +| 0 | `id` | string | Уникальный ID эскроу (задаёт пользователь) | +| 1 | `seller` | string | Pubkey продавца (hex) | +| 2 | `amount` | uint64 | Сумма в µT | + +**Поведение:** +- Переводит `amount` с caller (buyer) → treasury +- Сохраняет buyer, seller, amount, status=active +- Лог: `created: <id>` + +**Ограничения:** +- ID должен быть уникальным — если `cstate[e:<id>:b]` уже есть, tx отклоняется + +```bash +client call-contract --method create \ + --arg "deal-001" \ + --arg <SELLER_PUBKEY> \ + --arg64 10000000 \ + --contract $ESC_ID --key /tmp/buyer.json \ + --gas 30000 --node http://node1:8080 +``` + +--- + +### `release` + +Buyer подтверждает получение и освобождает средства seller'у. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `id` | string | + +**Права:** только buyer. +**Статус:** должен быть `active`. + +**Поведение:** +- Переводит `amount` с treasury → seller +- Статус → released +- Лог: `released: <id>` + +```bash +client call-contract --method release --arg "deal-001" \ + --contract $ESC_ID --key /tmp/buyer.json \ + --gas 20000 --node http://node1:8080 +``` + +--- + +### `refund` + +Seller добровольно возвращает деньги buyer'у. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `id` | string | + +**Права:** только seller. +**Статус:** должен быть `active`. + +**Поведение:** +- Переводит `amount` с treasury → buyer +- Статус → refunded +- Лог: `refunded: <id>` + +```bash +client call-contract --method refund --arg "deal-001" \ + --contract $ESC_ID --key /tmp/seller.json \ + --gas 20000 --node http://node1:8080 +``` + +--- + +### `dispute` + +Открыть спор. Может вызвать buyer или seller. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `id` | string | + +**Статус:** должен быть `active`. + +**Поведение:** +- Статус → disputed +- Лог: `disputed: <id>` +- Средства остаются заблокированы в treasury + +```bash +client call-contract --method dispute --arg "deal-001" \ + --contract $ESC_ID --key /tmp/buyer.json \ + --gas 10000 --node http://node1:8080 +``` + +--- + +### `resolve` + +Admin разрешает спор. + +**Аргументы:** +| # | Имя | Тип | Значения | +|---|-----|-----|---------| +| 0 | `id` | string | ID эскроу | +| 1 | `winner` | string | `buyer` или `seller` | + +**Права:** только admin. +**Статус:** должен быть `disputed`. + +**Поведение:** +- `winner=buyer` → treasury → buyer, статус → refunded +- `winner=seller` → treasury → seller, статус → released +- Лог: `resolved: <id>` + +```bash +# Admin решает в пользу buyer +client call-contract --method resolve \ + --arg "deal-001" --arg buyer \ + --contract $ESC_ID --key /keys/node1.json \ + --gas 20000 --node http://node1:8080 + +# Admin решает в пользу seller +client call-contract --method resolve \ + --arg "deal-001" --arg seller \ + --contract $ESC_ID --key /keys/node1.json \ + --gas 20000 --node http://node1:8080 +``` + +--- + +### `info` + +Запросить состояние эскроу. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `id` | string | + +**Логи:** +``` +buyer: <pubkey> +seller: <pubkey> +amount: <µT> +status: active|released|refunded|disputed +``` + +```bash +client call-contract --method info --arg "deal-001" \ + --contract $ESC_ID --key /keys/node1.json \ + --gas 5000 --node http://node1:8080 +``` + +--- + +## State Layout + +``` +cstate:<contractID>:admin → admin pubkey +cstate:<contractID>:e:<id>:b → buyer pubkey +cstate:<contractID>:e:<id>:s → seller pubkey +cstate:<contractID>:e:<id>:a → amount (uint64 big-endian) +cstate:<contractID>:e:<id>:x → status byte ('a'=active, 'd'=disputed, 'r'=released, 'f'=refunded) +``` + +## Полный сценарий с dispute + +```bash +# Параметры +BUYER_KEY=/tmp/buyer.json +SELLER_KEY=/tmp/seller.json +ADMIN_KEY=/keys/node1.json +ID="escrow-001" + +# 1. Buyer создаёт эскроу на 5 T +docker exec node1 client call-contract \ + --key $BUYER_KEY --contract $ESC_ID \ + --method create \ + --arg $ID --arg $SELLER_PUB --arg64 5000000 \ + --gas 30000 --node http://node1:8080 + +# 2. Проверить статус +docker exec node1 client call-contract \ + --key $BUYER_KEY --contract $ESC_ID \ + --method info --arg $ID \ + --gas 5000 --node http://node1:8080 +# status: active + +# 3. Buyer не доволен — открывает спор +docker exec node1 client call-contract \ + --key $BUYER_KEY --contract $ESC_ID \ + --method dispute --arg $ID \ + --gas 10000 --node http://node1:8080 + +# 4. Admin рассматривает дело и решает в пользу seller +docker exec node1 client call-contract \ + --key $ADMIN_KEY --contract $ESC_ID \ + --method resolve --arg $ID --arg seller \ + --gas 20000 --node http://node1:8080 +# Лог: resolved: escrow-001 +# Seller получает 5 T +``` diff --git a/docs/contracts/governance.md b/docs/contracts/governance.md new file mode 100644 index 0000000..84e69ed --- /dev/null +++ b/docs/contracts/governance.md @@ -0,0 +1,203 @@ +# governance + +On-chain governance контракт для управления параметрами сети. Deployer становится admin. Любой может предложить изменение параметра; admin принимает или отклоняет. + +## Концепция + +``` +Участник Admin + │ │ + │── propose ──▶ │ state[prop:<key>] = value + │ │ + │ │── approve ──▶ state[param:<key>] = value (live) + │ │ удаляет prop:<key> + │ │ + │ │── reject ──▶ удаляет prop:<key> +``` + +После approve параметр становится **live** и читается нодой при следующем вызове контракта. + +## Методы + +### `init` + +Инициализировать контракт, установить caller как admin. + +**Аргументы:** нет + +**Вызывается:** один раз после деплоя (deploy-скрипт делает это автоматически). + +**Поведение:** +- Устанавливает `state[admin] = caller_pubkey` +- Идемпотентен: повторный вызов не меняет admin если уже установлен + +```bash +client call-contract --method init --contract $GOV_ID \ + --key /keys/node1.json --gas 5000 --node http://node1:8080 +``` + +--- + +### `propose` + +Предложить новое значение параметра. Доступно всем. + +**Аргументы:** +| # | Имя | Тип | Ограничение | +|---|-----|-----|------------| +| 0 | `key` | string | max 64 байта | +| 1 | `value` | string | max 256 байт | + +**Поведение:** +- Сохраняет `state[prop:<key>] = value` +- Лог: `proposed: <key>=<value>` +- Если pending-предложение уже есть → перезаписывает + +```bash +# Предложить новую цену газа 5 µT/gas +client call-contract --method propose \ + --arg gas_price --arg 5 \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 10000 --node http://node1:8080 +``` + +--- + +### `approve` + +Admin принимает pending-предложение. Параметр становится live. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `key` | string | + +**Права:** только admin. + +**Поведение:** +- Копирует `state[prop:<key>]` → `state[param:<key>]` +- Удаляет `state[prop:<key>]` +- Лог: `approved: <key>` +- Если нет pending → лог `no pending: <key>` + +```bash +client call-contract --method approve --arg gas_price \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 10000 --node http://node1:8080 +``` + +--- + +### `reject` + +Admin отклоняет pending-предложение. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `key` | string | + +**Права:** только admin. + +```bash +client call-contract --method reject --arg gas_price \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 5000 --node http://node1:8080 +``` + +--- + +### `get` + +Прочитать текущее live-значение параметра. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `key` | string | + +**Лог:** +- `value: <value>` — параметр установлен +- `not set: <key>` — параметр не установлен + +```bash +client call-contract --method get --arg gas_price \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 3000 --node http://node1:8080 +``` + +Через REST (без транзакции): +```bash +curl http://localhost:8081/api/contracts/$GOV_ID/state/param:gas_price +``` + +--- + +### `get_pending` + +Прочитать pending-предложение. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `key` | string | + +**Лог:** +- `pending: <value>` +- `no pending: <key>` + +--- + +### `set_admin` + +Передать роль admin другому адресу. + +**Аргументы:** +| # | Имя | Тип | +|---|-----|-----| +| 0 | `new_admin` | string (hex pubkey) | + +**Права:** только текущий admin. + +```bash +client call-contract --method set_admin --arg <NEW_ADMIN_PUBKEY> \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 10000 --node http://node1:8080 +``` + +--- + +## Управляемые параметры + +Governance хранит произвольные string-параметры. Нода читает следующие: + +| Ключ | Тип | Описание | По умолчанию | +|------|-----|---------|-------------| +| `gas_price` | uint64 (строка) | µT за 1 gas unit | 1 µT | + +> Нода читает `gas_price` при каждом `CALL_CONTRACT` через `chain.GetEffectiveGasPrice()`. +> Изменение вступает в силу немедленно после `approve` — без перезапуска. + +Можно хранить любые параметры приложения (messenger_entry_fee, relay_fee, etc.) и читать их из других контрактов через межконтрактные вызовы. + +## State Layout + +``` +cstate:<contractID>:admin → admin pubkey (hex string bytes) +cstate:<contractID>:param:<key> → live value +cstate:<contractID>:prop:<key> → pending proposal +``` + +## Интеграция с нодой + +После деплоя governance необходимо его **привязать** к ноде: + +```bash +# Автоматически через deploy-скрипт +# Или вручную: +curl -X POST http://localhost:8081/api/governance/link \ + -H "Content-Type: application/json" \ + -d "{\"governance\": \"$GOV_ID\"}" +``` + +Подробнее: [Governance интеграция](../node/governance.md) diff --git a/docs/contracts/username_registry.md b/docs/contracts/username_registry.md new file mode 100644 index 0000000..d1f04b9 --- /dev/null +++ b/docs/contracts/username_registry.md @@ -0,0 +1,133 @@ +# username_registry + +Привязка читаемых `@username` к адресам (Ed25519 pubkey). Forward +(`name → addr`) и reverse (`addr → name`) lookup, передача ownership, +освобождение имени. + +**Реализация: native Go** (`blockchain/native_username.go`). Работает +без WASM VM — нулевая латентность, не может повесить AddBlock. Старая +WASM-версия заменена; клиенты получают канонический ID через +`/api/well-known-contracts`. + +## Ключевые свойства + +| | | +|---|---| +| **Contract ID** | `native:username_registry` | +| **Version** | `2.1.0-native` | +| **Регистрация** | Flat fee 10 000 µT (0.01 T), **burn** — никому не идёт | +| **Ограничения имени** | 4–32 символа, `a-z 0-9 _ -`, первый символ — буква | +| **Зарезервированные** | `system`, `admin`, `root`, `dchain`, `null`, `none` | +| **Один адрес — одно имя** | Чтобы сменить, нужно `release` + `register` | + +Комиссия 10 000 µT выбрана как баланс: достаточно дёшево для реальных +пользователей, достаточно дорого чтобы один ID-squatter не забил тысячу +имён из скучающего бота. + +## Оплата + +Плата передаётся в `tx.Amount` (видна в истории транзакций), контракт +проверяет её точное значение и вычитает из баланса отправителя. Не +направляется никуда — токены сгорают. + +Дополнительно платится `tx.Fee = 1 000 µT` за саму транзакцию — это +network fee валидатору, стандартный `MinFee` для любой tx. + +Итого: `register(name)` стоит **11 000 µT ≈ 0.011 T**. + +## Методы + +### `register(name)` — payable 10000 + +Зарегистрировать имя для `tx.From`. + +**Проверки** (в порядке): +1. `name` валиден (длина, символы, не reserved) +2. `tx.Amount == 10 000` (ошибка `register requires tx.amount = 10000 µT`) +3. Имя не занято +4. `tx.From` не владеет другим именем +5. Баланс отправителя достаточен для `amount + fee + gas` + +**Успех:** +- Списывает 10 000 µT с баланса (burn) +- `cstate[name:<name>] = tx.From` +- `cstate[addr:<tx.From>] = name` +- Лог `registered: <name> → <pubkey>` + +**CLI-пример:** +```bash +client call-contract \ + --key my.json \ + --contract native:username_registry \ + --method register \ + --args '["alice"]' \ + --amount 10000 \ + --node http://localhost:8081 +``` + +**Клиент-приложение** делает это автоматически через Settings → +«Купить никнейм». + +### `resolve(name)` — free + +Прочитать владельца имени. + +Логирует `owner: <pubkey>` или `not found: <name>`. Клиент в HTTP-API +обычно использует прямое чтение state: +``` +GET /api/contracts/native:username_registry/state/name:alice +→ { value_hex: "<hex of owner pubkey ASCII>" } +``` + +### `lookup(address)` — free + +Обратный lookup. Логирует `name: <name>` или `no name: <address>`. +Клиент использует `GET .../state/addr:<pubkey>`. + +### `transfer(name, new_owner)` — free + +Передать `name` другому адресу. Только текущий владелец может вызвать. +Новый владелец не должен уже иметь имя. + +### `release(name)` — free + +Удалить привязку. Только текущий владелец. Освобождает `name` и +`addr:<caller>` для будущих регистраций. + +## State layout + +Все ключи записываются в общий namespace `cstate:native:username_registry:`: + +| Key | Value | +|-----|-------| +| `name:<name>` | Hex pubkey владельца (64 символа) | +| `addr:<pubkey>` | ASCII-строка с именем | + +Значения хранятся как байты; HTTP endpoint +`/api/contracts/:id/state/:key` возвращает их в `value_hex`, клиент +делает `hex → UTF-8` перед показом. + +## Клиентская интеграция + +- `useWellKnownContracts` хук авто-синхронизирует `settings.contractId = + native:username_registry` +- `resolveUsername(contractId, name)` и `reverseResolve(contractId, addr)` + в `client-app/lib/api.ts` делают HTTP-запрос + hex-decode +- Settings экран покупает никнейм через `buildCallContractTx({amount: + 10_000, ...})` + автоматический `reverseResolve` polling после submit + для показа `@name` в профиле за ~1 блок + +## Отличия от предыдущей WASM-версии + +| | WASM v1 | Native v2 | +|---|---------|-----------| +| **Fee** | 10^(7-len), tiered | Flat 10 000 µT | +| **Fee destination** | Contract treasury | Burn | +| **Min length** | 1 | **4** | +| **Payment point** | Debited inside contract, invisible in tx | `tx.Amount`, visible in history | +| **Latency** | ~10 ms (wazero startup) | ~50 µs (direct Go call) | +| **VM hang risk** | Да (требует timeout) | Нет | + +Зарегистрированные в старой WASM-версии имена не мигрируются +автоматически — операторам нужно пере-зарегистрировать после reset +chain (см. `CHANGELOG.md` «Compatibility notes»). diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 0000000..86d6898 --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,103 @@ +# Разработка контрактов + +DChain поддерживает два способа написания WASM-контрактов. + +## Выбор подхода + +| | TinyGo SDK | Бинарный WASM | +|-|-----------|--------------| +| **Язык** | Go | Go (кодогенератор) | +| **Инструменты** | TinyGo 0.30+ | Стандартный Go | +| **Сложность** | Низкая | Высокая | +| **Размер .wasm** | ~30–100 KB | 500–2000 байт | +| **Отладка** | Стандартная | Сложная | +| **Рекомендуется** | Новые контракты | Минимальные/системные | + +## TinyGo SDK (рекомендуется) + +Пишите контракты как обычный Go-код: + +```go +package main + +import dc "go-blockchain/contracts/sdk" + +//export increment +func increment() { + v := dc.GetU64("counter") + dc.PutU64("counter", v+1) + dc.Log("incremented") +} + +func main() {} +``` + +Подробное руководство: [TinyGo SDK](tinygo.md) + +## Бинарный WASM + +Генераторы в `contracts/*/gen/main.go` создают минимальные WASM-модули вручную через LEB128-кодирование. Размер получается ~500 байт, но написание сложное. + +Подробнее: [Бинарный WASM](binary-wasm.md) + +## Документация по разделам + +| Документ | Содержание | +|---------|-----------| +| [TinyGo SDK](tinygo.md) | Установка, SDK API, сборка, деплой, пример | +| [Host functions](host-functions.md) | Полный справочник 14 host-функций | +| [Межконтрактные вызовы](inter-contract.md) | call_contract, composability, глубина | +| [Бинарный WASM](binary-wasm.md) | LEB128, секции, кодогенератор | +| [Gas и Treasury](gas-model.md) | Gas модель, treasury, governance | + +## Общая структура контракта + +``` +contracts/ + mycontract/ + main.go # TinyGo источник + mycontract_abi.json # ABI (описание методов) + mycontract.wasm # Скомпилированный бинарник +``` + +## ABI формат + +```json +{ + "contract": "mycontract", + "version": "1.0.0", + "description": "...", + "methods": [ + { + "name": "method_name", + "description": "...", + "args": [ + {"name": "param1", "type": "string"}, + {"name": "amount", "type": "uint64"} + ] + } + ] +} +``` + +Поддерживаемые типы: `string`, `uint64`, `bytes`. + +## Деплой контракта + +```bash +# Локально +client deploy-contract \ + --key key.json \ + --wasm mycontract.wasm \ + --abi mycontract_abi.json \ + --node http://localhost:8081 + +# В Docker +docker exec node1 client deploy-contract \ + --key /keys/node1.json \ + --wasm /path/to/mycontract.wasm \ + --abi /path/to/mycontract_abi.json \ + --node http://node1:8080 +``` + +Успешный деплой логирует `contract_id: <hex16>`. diff --git a/docs/development/binary-wasm.md b/docs/development/binary-wasm.md new file mode 100644 index 0000000..a7db551 --- /dev/null +++ b/docs/development/binary-wasm.md @@ -0,0 +1,249 @@ +# Бинарный WASM + +Альтернативный способ написания контрактов — генерация WASM-байткода вручную. Используется для системных/минимальных контрактов где критичен размер (~500–2000 байт вместо 30–100 KB TinyGo). + +> **Рекомендуется TinyGo** для новых контрактов. Бинарный WASM используется для встроенных контрактов в этом проекте. + +## Структура генератора + +``` +contracts/ + mycontract/ + gen/ + main.go # Go-программа, печатает WASM байты + mycontract.wasm # Результат: go run gen/main.go > mycontract.wasm + mycontract_abi.json +``` + +Генератор запускается стандартным Go (`go run gen/main.go`) и выводит бинарный WASM в stdout. + +## Анатомия WASM модуля + +``` +Magic + Version : \0asm\x01\x00\x00\x00 + +Секции (по порядку): + 1. Type — сигнатуры функций + 2. Import — импортируемые host-функции ("env" модуль) + 3. Function — индексы типов для локальных функций + 4. Export — экспортируемые функции (методы контракта) + 5. Code — тела функций + 6. Data — статические строки в памяти + 7. Memory — объявление памяти (мин. 1 страница = 64 KB) +``` + +## LEB128 кодирование + +Целые числа в WASM кодируются в LEB128 (variable-length encoding). + +```go +// Unsigned LEB128 +func u(v uint64) []byte { + var out []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if v != 0 { + b |= 0x80 + } + out = append(out, b) + if v == 0 { + break + } + } + return out +} + +// Signed LEB128 (для i32/i64 константы) +func s(v int64) []byte { + var out []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if (v == 0 && b&0x40 == 0) || (v == -1 && b&0x40 != 0) { + out = append(out, b) + break + } + out = append(out, b|0x80) + } + return out +} +``` + +## Вспомогательные функции + +```go +// Секция с длиной-префиксом +func section(id byte, content []byte) []byte { + return append(append([]byte{id}, u(uint64(len(content)))...), content...) +} + +// Вектор (count + элементы) +func vec(items ...[]byte) []byte { + out := u(uint64(len(items))) + for _, item := range items { + out = append(out, item...) + } + return out +} + +// Строка с длиной-префиксом +func str(s string) []byte { + return append(u(uint64(len(s))), []byte(s)...) +} +``` + +## WASM инструкции + +Наиболее используемые опкоды: + +| Инструкция | Байт | Описание | +|-----------|------|---------| +| `local.get` | `0x20 + leb(idx)` | Прочитать локальную переменную | +| `local.set` | `0x21 + leb(idx)` | Записать локальную переменную | +| `local.tee` | `0x22 + leb(idx)` | Записать и оставить на стеке | +| `i32.const` | `0x41 + sleb(val)` | Константа i32 | +| `i64.const` | `0x42 + sleb(val)` | Константа i64 | +| `i32.load` | `0x28 0x02 leb(offset)` | Загрузить 4 байта | +| `i32.store` | `0x36 0x02 leb(offset)` | Сохранить 4 байта | +| `i64.load` | `0x29 0x03 leb(offset)` | Загрузить 8 байт | +| `i64.store` | `0x37 0x03 leb(offset)` | Сохранить 8 байт | +| `i32.add` | `0x6A` | Сложение i32 | +| `i32.sub` | `0x6B` | Вычитание i32 | +| `i32.eq` | `0x46` | Равенство i32 | +| `i64.eq` | `0x51` | Равенство i64 | +| `if` | `0x04 0x40` | Ветвление (void) | +| `else` | `0x05` | — | +| `end` | `0x0B` | Конец блока/функции | +| `return` | `0x0F` | Возврат из функции | +| `call` | `0x10 + leb(funcIdx)` | Вызов функции | +| `drop` | `0x1A` | Убрать верхний элемент стека | + +## Шаблон генератора + +```go +package main + +import ( + "os" +) + +// Импорты host-функций +const ( + fnGetArgStr = 0 // индекс в таблице импортов + fnSetState = 1 + fnGetState = 2 + fnGetStateLen = 3 + fnGetCaller = 4 + fnLog = 5 +) + +// Смещения в памяти +const ( + pKey = 0 // буфер для ключей state + pVal = 128 // буфер для значений + pCaller = 256 // буфер для caller + pArg0 = 320 // буфер для аргумента 0 + pStatic = 400 // статические строки (из Data секции) +) + +func main() { + // 1. Magic + version + wasm := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00} + + // 2. Type секция — сигнатуры функций + // ... + + // 3. Import секция — host функции + // ... + + // 4. Function + Memory + Export + Code + Data + // ... + + os.Stdout.Write(wasm) +} +``` + +## Пример: функция increment + +Аналог Go-кода: +```go +func increment() { + count := getU64("counter") + putU64("counter", count+1) + log("incremented") +} +``` + +В бинарном WASM: +```go +// В Data секции: "counter\x00" @ offset 0, "incremented\x00" @ offset 8 +// Функция increment: +func buildIncrement() []byte { + // locals: none + body := []byte{0x00} // local decl count = 0 + + // load key "counter" (ptr=0, len=7) → call get_u64 → result on stack + body = append(body, 0x41) // i32.const + body = append(body, s(0)...) // ptr = 0 (offset of "counter" in data) + body = append(body, 0x41) + body = append(body, s(7)...) // len = 7 + body = append(body, 0x10) // call + body = append(body, u(fnGetU64)...) + + // add 1 + body = append(body, 0x42, 0x01) // i64.const 1 + body = append(body, 0x7C) // i64.add + + // put_u64("counter", result) + body = append(body, 0x21, 0x00) // local.set 0 (tmp) + body = append(body, 0x41) + body = append(body, s(0)...) + body = append(body, 0x41) + body = append(body, s(7)...) + body = append(body, 0x20, 0x00) // local.get 0 + body = append(body, 0x10) + body = append(body, u(fnPutU64)...) + + // log("incremented") + body = append(body, 0x41) + body = append(body, s(8)...) // ptr = 8 (offset of "incremented") + body = append(body, 0x41) + body = append(body, s(11)...) // len = 11 + body = append(body, 0x10) + body = append(body, u(fnLog)...) + + body = append(body, 0x0B) // end + return append(u(uint64(len(body))), body...) +} +``` + +## Сборка и деплой + +```bash +# Генерация +go run contracts/mycontract/gen/main.go > contracts/mycontract/mycontract.wasm + +# Проверка (wasm-objdump из wabt) +wasm-objdump -d contracts/mycontract/mycontract.wasm + +# Деплой +client deploy-contract \ + --key key.json \ + --wasm contracts/mycontract/mycontract.wasm \ + --abi contracts/mycontract/mycontract_abi.json \ + --node http://localhost:8081 +``` + +## Встроенные контракты в проекте + +| Контракт | Генератор | Размер .wasm | +|---------|----------|-------------| +| counter | contracts/counter/gen/main.go | ~500 байт | +| governance | contracts/governance/gen/main.go | ~800 байт | +| name_registry | contracts/name_registry/gen/main.go | ~1.2 KB | +| username_registry | contracts/username_registry/gen/main.go | ~1.5 KB | +| escrow | contracts/escrow/gen/main.go | ~2 KB | +| auction | contracts/auction/gen/main.go | ~2.5 KB | + +Все генераторы используют одни и те же LEB128-утилиты и паттерн `vec/section/str`. diff --git a/docs/development/gas-model.md b/docs/development/gas-model.md new file mode 100644 index 0000000..acb45e4 --- /dev/null +++ b/docs/development/gas-model.md @@ -0,0 +1,193 @@ +# Gas и Treasury + +## Gas модель + +Каждый вызов контракта (`CALL_CONTRACT` транзакция) тратит gas. Gas конвертируется в µT и списывается со счёта отправителя. + +### Формула + +``` +fee = gas_used × gas_price +``` + +| Параметр | Значение | Откуда | +|---------|---------|-------| +| `gas_used` | ≤ `--gas` лимит | считается VM | +| `gas_price` | 1 µT/gas по умолчанию | governance или константа | + +### Лимит gas + +Задаётся в транзакции флагом `--gas`: + +```bash +client call-contract --method increment \ + --gas 5000 \ + --contract $CONTRACT_ID --key key.json --node http://localhost:8081 +``` + +Если VM израсходует весь лимит до конца исполнения — транзакция откатывается, gas не возвращается. + +### Что стоит gas + +| Операция | Стоимость | +|---------|---------| +| Итерация цикла (`gas_tick`) | 1 unit | +| Внешний вызов (`call_contract`) | gas подвызова | +| Прочие инструкции | 0 (не инструментированы) | + +> TinyGo автоматически вставляет `gas_tick` в циклы. Бинарные WASM-контракты вызывают `gas_tick` вручную. + +### Межконтрактный gas + +При вызове `dc.CallContract(...)`, подвызов получает budget = `Remaining()` родителя. +После возврата, `gasUsed` подвызова списывается с родительского счётчика. + +``` +Родитель: limit=5000, used=200 + → подвызов budget = 4800 + → подвызов использовал 300 +Родитель: used = 200 + 300 = 500 +``` + +## Treasury + +Каждый контракт имеет **treasury** — специальный баланс, привязанный к контракту. + +### Получить адрес treasury + +```go +treasury := dc.Treasury() // hex pubkey, 64 символа +``` + +### Переводы через treasury + +Treasury используется как промежуточный эскроу: + +```go +// Принять деньги от caller → treasury +dc.Transfer(dc.Caller(), dc.Treasury(), amount) + +// Отправить деньги из treasury → получателю +dc.Transfer(dc.Treasury(), recipient, amount) +``` + +**Ограничение:** в `dc.Transfer(from, ...)`, `from` может быть только: +1. `dc.Caller()` — списать со счёта вызывающего +2. `dc.Treasury()` — списать с treasury контракта + +Контракт **не может** списывать деньги с произвольных адресов. + +### Проверить баланс treasury + +```go +treasuryBal := dc.Balance(dc.Treasury()) +``` + +### Паттерны использования treasury + +**1. Fee collector:** +```go +const fee = 1000 // µT +dc.Transfer(dc.Caller(), dc.Treasury(), fee) +// Деньги остаются в treasury навсегда (или до явного вывода) +``` + +**2. Эскроу (lock → release):** +```go +// lock: buyer → treasury +dc.Transfer(buyer, dc.Treasury(), amount) + +// release: treasury → seller +dc.Transfer(dc.Treasury(), seller, amount) + +// refund: treasury → buyer +dc.Transfer(dc.Treasury(), buyer, amount) +``` + +**3. Prize pool (аукцион):** +```go +// Принять ставку +dc.Transfer(bidder, dc.Treasury(), bid) + +// Возврат предыдущей ставки +dc.Transfer(dc.Treasury(), prevBidder, prevBid) + +// Финал: перевод победителю +dc.Transfer(dc.Treasury(), seller, topBid) +``` + +## Governance и динамический gas_price + +По умолчанию `gas_price = 1 µT/gas`. Это значение можно изменить через governance-контракт. + +### Как нода читает gas_price + +```go +// blockchain/chain.go +func (c *Chain) GetEffectiveGasPrice() uint64 { + val, ok := c.GetGovParam("gas_price") + if !ok { return GasPrice } // константа по умолчанию + price, err := strconv.ParseUint(val, 10, 64) + if err != nil { return GasPrice } + return price +} +``` + +`GetGovParam` читает `cstate:<govID>:param:gas_price` напрямую из BadgerDB без VM-вызова. + +### Изменить gas_price через governance + +```bash +# Предложить новое значение (5 µT/gas) +client call-contract --method propose \ + --arg gas_price --arg 5 \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 10000 --node http://node1:8080 + +# Admin утверждает +client call-contract --method approve \ + --arg gas_price \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 10000 --node http://node1:8080 +``` + +После approve все последующие `CALL_CONTRACT` транзакции будут использовать новую цену. + +### Проверить текущий gas_price + +```bash +# Через контракт +client call-contract --method get \ + --arg gas_price \ + --contract $GOV_ID --key key.json \ + --gas 3000 --node http://localhost:8081 + +# Через REST (без транзакции) +curl http://localhost:8081/api/contracts/$GOV_ID/state/param:gas_price +``` + +## Константы + +```go +// blockchain/chain.go +const ( + GasPrice = uint64(1) // µT за 1 gas unit (default) +) + +// vm/host.go +const ( + maxContractCallDepth = 8 +) +``` + +## Рекомендации по выбору --gas + +| Операция | Рекомендуемый gas | +|---------|-----------------| +| Простое чтение/запись state | 3 000 | +| Регистрация / короткий метод | 5 000 | +| Метод с несколькими state операциями | 10 000 | +| Создание эскроу/аукциона | 20 000 – 30 000 | +| Метод с межконтрактным вызовом | 30 000 – 50 000 | + +Gas, который не был потрачён, **не возвращается**. Завышенный лимит безопасен, но лишних денег не списывается больше `gas_used × gas_price`. diff --git a/docs/development/host-functions.md b/docs/development/host-functions.md new file mode 100644 index 0000000..bed5673 --- /dev/null +++ b/docs/development/host-functions.md @@ -0,0 +1,274 @@ +# Host Functions + +Полный справочник 14 host-функций, которые DChain экспортирует в WASM-контракты через модуль `env`. + +Все функции регистрируются в `vm/host.go`. Контракт импортирует их через `//go:wasmimport env <name>` (TinyGo) или напрямую в секции `imports` WASM-модуля. + +--- + +## Аргументы + +### `get_args` + +``` +get_args(dstPtr i32, dstLen i32) → written i32 +``` + +Читает весь JSON-массив аргументов транзакции в буфер `[dstPtr, dstPtr+dstLen)`. +Возвращает количество записанных байт (0 если аргументов нет). + +--- + +### `get_arg_str` + +``` +get_arg_str(idx i32, dstPtr i32, dstLen i32) → written i32 +``` + +Читает аргумент с индексом `idx` как строку (без кавычек JSON). +`dstLen` — максимальная длина буфера. +Возвращает 0 если аргумент не существует или тип не строка. + +**SDK:** `dc.ArgStr(idx, maxLen)` + +--- + +### `get_arg_u64` + +``` +get_arg_u64(idx i32) → val i64 +``` + +Читает аргумент с индексом `idx` как беззнаковое 64-битное число. +Возвращает 0 если индекс вне диапазона или тип не число. + +**SDK:** `dc.ArgU64(idx)` + +--- + +## State + +### `get_state_len` + +``` +get_state_len(keyPtr i32, keyLen i32) → valLen i32 +``` + +Возвращает размер значения по ключу (0 если ключ не найден). +Используется перед `get_state` для выделения буфера нужного размера. + +--- + +### `get_state` + +``` +get_state(keyPtr i32, keyLen i32, dstPtr i32, dstLen i32) → written i32 +``` + +Читает `min(len(value), dstLen)` байт по ключу в буфер. +Возвращает фактически записанное количество байт. + +**SDK:** `dc.GetState(key)` (выделяет буфер автоматически через `get_state_len`) + +--- + +### `set_state` + +``` +set_state(keyPtr i32, keyLen i32, valPtr i32, valLen i32) +``` + +Записывает значение по ключу. Если `valLen == 0` — удаляет ключ. + +**SDK:** `dc.SetState(key, value)`, `dc.SetStateStr(key, s)`, `dc.PutU64(key, v)` + +--- + +### `put_u64` + +``` +put_u64(keyPtr i32, keyLen i32, val i64) +``` + +Записывает `val` как 8 байт big-endian. Эквивалентно `set_state` с 8-байтным big-endian значением, но компактнее в вызове. + +**SDK:** `dc.PutU64(key, v)` + +--- + +### `get_u64` + +``` +get_u64(keyPtr i32, keyLen i32) → val i64 +``` + +Читает 8 байт big-endian по ключу. Возвращает 0 если ключ не найден. + +**SDK:** `dc.GetU64(key)` + +--- + +## Идентификация + +### `get_caller` + +``` +get_caller(bufPtr i32, bufLen i32) → written i32 +``` + +Записывает hex-pubkey вызывающего аккаунта (64 символа ASCII). +Если `bufLen < 64`, записывает частично. + +**SDK:** `dc.Caller()` + +--- + +### `get_block_height` + +``` +get_block_height() → height i64 +``` + +Возвращает высоту текущего блока (в котором исполняется транзакция). + +**SDK:** `dc.BlockHeight()` + +--- + +### `get_contract_treasury` + +``` +get_contract_treasury(bufPtr i32, bufLen i32) → written i32 +``` + +Записывает hex-pubkey treasury контракта (64 символа). +Treasury — специальный счёт, привязанный к контракту. Контракт может переводить с treasury как `from` без ограничений. + +**SDK:** `dc.Treasury()` + +--- + +## Токены + +### `get_balance` + +``` +get_balance(pubPtr i32, pubLen i32) → balance i64 +``` + +Возвращает баланс адреса в µT (микро-токены). + +**SDK:** `dc.Balance(pubKeyHex)` + +--- + +### `transfer` + +``` +transfer(fromPtr i32, fromLen i32, toPtr i32, toLen i32, amount i64) → errCode i32 +``` + +Переводит `amount` µT с `from` на `to`. +Возвращает 0 при успехе, 1 при ошибке (недостаточно средств, неверный адрес). + +**Ограничения:** +- `from` должен быть либо `dc.Caller()`, либо `dc.Treasury()`. +- Контракт не может тратить чужие деньги (только caller'а или свой treasury). + +**SDK:** `dc.Transfer(from, to, amount) bool` + +--- + +## Межконтрактные вызовы + +### `call_contract` + +``` +call_contract(cidPtr i32, cidLen i32, mthPtr i32, mthLen i32, argPtr i32, argLen i32) → errCode i32 +``` + +Вызывает метод другого контракта. + +| Параметр | Тип | Описание | +|---------|-----|---------| +| `cidPtr/cidLen` | i32 | hex-ID целевого контракта (16 символов) | +| `mthPtr/mthLen` | i32 | имя метода | +| `argPtr/argLen` | i32 | JSON-массив аргументов | + +**Возврат:** 0 = успех, 1 = ошибка (превышена глубина, контракт не найден, и т.д.) + +**Gas:** подвызов получает `gc.Remaining()` от родительского счётчика. Потраченный gas вычитается из родителя. + +**State:** все вызовы в цепочке разделяют одну `badger.Txn`. Если подвызов не падает, его изменения видны родителю. + +**Подробнее:** [Межконтрактные вызовы](inter-contract.md) + +**SDK:** `dc.CallContract(contractID, method, argsJSON) bool` + +--- + +## Логирование + +### `log` + +``` +log(msgPtr i32, msgLen i32) +``` + +Записывает строку в лог контракта. Логи привязаны к транзакции и видны в Explorer → вкладка Logs. + +`fmt.Println` и `log.Printf` через WASI stdout **не** попадают в блокчейн-логи. Используйте только `log`. + +**SDK:** `dc.Log(msg)` + +--- + +## Gas + +### `gas_tick` + +``` +gas_tick() +``` + +Вызывается автоматически при каждой итерации цикла в бинарных WASM-контрактах (инструментация вручную). Списывает 1 unit gas. + +Для TinyGo-контрактов TinyGo генерирует собственную инструментацию. + +--- + +## Таблица функций + +| # | Имя | Сигнатура | SDK | +|---|-----|----------|-----| +| 1 | `get_args` | `(i32,i32)→i32` | — | +| 2 | `get_arg_str` | `(i32,i32,i32)→i32` | `ArgStr` | +| 3 | `get_arg_u64` | `(i32)→i64` | `ArgU64` | +| 4 | `get_state_len` | `(i32,i32)→i32` | внутри `GetState` | +| 5 | `get_state` | `(i32,i32,i32,i32)→i32` | `GetState` | +| 6 | `set_state` | `(i32,i32,i32,i32)` | `SetState` | +| 7 | `put_u64` | `(i32,i32,i64)` | `PutU64` | +| 8 | `get_u64` | `(i32,i32)→i64` | `GetU64` | +| 9 | `get_caller` | `(i32,i32)→i32` | `Caller` | +| 10 | `get_block_height` | `()→i64` | `BlockHeight` | +| 11 | `get_contract_treasury` | `(i32,i32)→i32` | `Treasury` | +| 12 | `get_balance` | `(i32,i32)→i64` | `Balance` | +| 13 | `transfer` | `(i32,i32,i32,i32,i64)→i32` | `Transfer` | +| 14 | `call_contract` | `(i32,i32,i32,i32,i32,i32)→i32` | `CallContract` | +| 15 | `log` | `(i32,i32)` | `Log` | +| 16 | `gas_tick` | `()` | — | + +--- + +## Паттерн прямого импорта (без SDK) + +Если вы пишете бинарный WASM вручную (без TinyGo), функции импортируются в секции `imports`: + +``` +(import "env" "get_state_len" (func (param i32 i32) (result i32))) +(import "env" "get_state" (func (param i32 i32 i32 i32) (result i32))) +(import "env" "set_state" (func (param i32 i32 i32 i32))) +... +``` + +Подробнее: [Бинарный WASM](binary-wasm.md) diff --git a/docs/development/inter-contract.md b/docs/development/inter-contract.md new file mode 100644 index 0000000..cbb066b --- /dev/null +++ b/docs/development/inter-contract.md @@ -0,0 +1,129 @@ +# Межконтрактные вызовы + +DChain поддерживает вызовы одного контракта из другого через host-функцию `call_contract`. Все вызовы в цепочке исполняются в рамках одной транзакции и одного `badger.Txn`. + +## Синтаксис (TinyGo SDK) + +```go +import dc "go-blockchain/contracts/sdk" + +// Вызвать метод с аргументами +ok := dc.CallContract(contractID, "method_name", `["arg1","arg2"]`) + +// Без аргументов +ok := dc.CallContract(contractID, "get_price", "[]") + +// С числовым аргументом +ok := dc.CallContract(contractID, "increment", `[42]`) +``` + +`contractID` — 16-символьный hex-ID контракта (как в Explorer). + +## Как это работает + +``` +tx CALL_CONTRACT → Contract A + │ + │ call_contract(B, "method", args) + ▼ + Contract B + │ + │ call_contract(C, "method", args) + ▼ + Contract C + │ + └── [возврат gasUsed] + │ + [gasUsed заряжается на B] + [возврат gasUsed] + │ + [gasUsed заряжается на A] +``` + +### Атомарность + +Все изменения состояния в цепочке вызовов используют **одну** `badger.Txn`. Если родительский вызов завершился ошибкой, изменения всех подвызовов откатываются вместе с ним. Если подвызов вернул 1 (ошибку), родитель должен сам решить: паниковать или продолжать. + +### Gas + +Gas-бюджет подвызова = `gc.Remaining()` родителя (оставшийся gas на момент вызова). После возврата, `gasUsed` подвызова списывается с родительского счётчика. Таким образом, весь gas всей цепочки вычитается из единственного `--gas` лимита транзакции. + +### Caller + +В подвызове `dc.Caller()` возвращает contractID **вызывающего контракта** (не исходного пользователя). + +``` +user → Contract A → Contract B + dc.Caller() == Contract A's ID +``` + +### Глубина + +Максимальная глубина вложенности: **8**. +`A → B → C → ... → H` (8 уровней) — допустимо. +Попытка вызвать 9-й уровень вернёт ошибку, транзакция откатится. + +## Пример: price oracle + +```go +// contracts/mycontract/main.go +package main + +import dc "go-blockchain/contracts/sdk" + +const priceOracleID = "abcd1234abcd1234" // ID oracle-контракта + +//export buy +func buy() { + amount := dc.ArgU64(0) + + // Узнать цену у другого контракта + if !dc.CallContract(priceOracleID, "get_price", "[]") { + dc.Log("oracle unavailable") + return + } + // После вызова цена может быть записана в shared state под известным ключом, + // или oracle мог сделать transfer напрямую. + + dc.Log("buy executed") +} + +func main() {} +``` + +## Пример: ping (hello_go) + +```go +//export ping +func ping() { + target := dc.ArgStr(0, 64) + if target == "" { + dc.Log("no target") + return + } + ok := dc.CallContract(target, "get", "[]") + if ok { + dc.Log("ping ok") + } else { + dc.Log("ping failed") + } +} +``` + +## Ограничения + +| Параметр | Значение | +|---------|---------| +| Максимальная глубина | 8 | +| Разделяемый state | одна `badger.Txn` | +| Caller в подвызове | contractID родителя | +| Gas бюджет подвызова | `Remaining()` родителя | +| Возврат данных | только через state (нет return value) | + +> **Нет возврата значений.** `call_contract` возвращает только 0/1 (успех/ошибка). Для передачи данных из подвызова обратно: запишите в state под известным ключом или используйте промежуточный общий контракт. + +## Безопасность + +- Не доверяйте `dc.Caller()` из подвызванного контракта — он будет contractID инициатора, который может быть любым задеплоенным контрактом. +- Проверяйте contractID перед вызовом, если вам важно с кем вы говорите. +- Помните что подвызов может изменять общий state — не вызывайте непроверенные контракты. diff --git a/docs/development/tinygo.md b/docs/development/tinygo.md new file mode 100644 index 0000000..e693fc1 --- /dev/null +++ b/docs/development/tinygo.md @@ -0,0 +1,287 @@ +# TinyGo SDK + +Руководство по написанию DChain smart contracts на Go с использованием TinyGo. + +## Установка TinyGo + +```bash +# macOS +brew install tinygo + +# Linux (deb) +wget https://github.com/tinygo-org/tinygo/releases/download/v0.32.0/tinygo_0.32.0_amd64.deb +sudo dpkg -i tinygo_0.32.0_amd64.deb + +# Windows +# Скачать installer с https://github.com/tinygo-org/tinygo/releases + +# Проверка +tinygo version +``` + +Требуется TinyGo **0.30+** (поддержка `//go:wasmimport` + target wasip1). + +## Структура контракта + +``` +contracts/ + mycontract/ + main.go # контракт + mycontract_abi.json # ABI + mycontract.wasm # собранный (gitignore или коммитим?) +``` + +Минимальный контракт: + +```go +package main + +import dc "go-blockchain/contracts/sdk" + +//export my_method +func myMethod() { + dc.Log("hello from Go contract!") +} + +// main обязателен для TinyGo, но не вызывается. +func main() {} +``` + +Каждая `//export <name>` функция становится вызываемым методом контракта. + +## SDK API + +Импорт: `import dc "go-blockchain/contracts/sdk"` + +### Аргументы + +```go +// Строковый аргумент по индексу (maxLen — максимальная длина буфера) +name := dc.ArgStr(0, 64) // args[0] as string +addr := dc.ArgStr(1, 128) // args[1] as string + +// Числовой аргумент (uint64) +amount := dc.ArgU64(0) // args[0] as uint64 +``` + +### State + +```go +// Читать/писать произвольные байты +data := dc.GetState("key") // []byte или nil +dc.SetState("key", []byte("value")) // записать +dc.SetState("key", nil) // удалить + +// Удобные string обёртки +s := dc.GetStateStr("owner") // string (пустой если нет) +dc.SetStateStr("owner", caller) // записать string + +// uint64 (хранится как 8-byte big-endian) +count := dc.GetU64("counter") // uint64 +dc.PutU64("counter", count+1) // записать +``` + +### Идентификация + +```go +caller := dc.Caller() // hex pubkey вызывающего (64 символа) +height := dc.BlockHeight() // uint64, текущий блок +treasury := dc.Treasury() // hex адрес treasury контракта +``` + +### Токены + +```go +// Баланс адреса в µT +bal := dc.Balance(pubkey) // uint64 + +// Перевод µT +ok := dc.Transfer(from, to, amount) // bool: true=success +``` + +`Transfer` в контексте контракта: `from` должен быть либо caller'ом, либо `dc.Treasury()` — только так контракт может тратить чужие деньги. + +### Межконтрактные вызовы + +```go +// Вызвать метод другого контракта +ok := dc.CallContract(contractID, "method", `["arg1","arg2"]`) + +// Без аргументов +ok := dc.CallContract(contractID, "get_price", "[]") +``` + +Подробнее: [Межконтрактные вызовы](inter-contract.md) + +### Логирование + +```go +dc.Log("message") // строка видна в Explorer → Logs +dc.Log("value: " + strconv.FormatUint(v, 10)) +``` + +## Паттерны + +### Owner-только метод + +```go +//export admin_action +func adminAction() { + owner := dc.GetStateStr("owner") + if owner == "" { + // первый вызов — устанавливаем owner + dc.SetStateStr("owner", dc.Caller()) + dc.Log("initialized") + return + } + if dc.Caller() != owner { + dc.Log("unauthorized") + return + } + // ... логика ... +} +``` + +### Платный метод (fee в treasury) + +```go +//export register +func register() { + name := dc.ArgStr(0, 64) + if name == "" { return } + + const fee = 1000 // µT + caller := dc.Caller() + treasury := dc.Treasury() + + // Снять fee с caller и положить в treasury + if !dc.Transfer(caller, treasury, fee) { + dc.Log("insufficient balance") + return + } + dc.SetStateStr("reg:"+name, caller) + dc.Log("registered: " + name) +} +``` + +### Эскроу с release + +```go +//export lock +func lock() { + id := dc.ArgStr(0, 64) + amount := dc.ArgU64(1) + buyer := dc.Caller() + treasury := dc.Treasury() + + if !dc.Transfer(buyer, treasury, amount) { + dc.Log("transfer failed") + return + } + dc.SetStateStr("buyer:"+id, buyer) + dc.PutU64("amount:"+id, amount) + dc.Log("locked: " + id) +} + +//export release +func release() { + id := dc.ArgStr(0, 64) + buyer := dc.GetStateStr("buyer:" + id) + if dc.Caller() != buyer { + dc.Log("unauthorized") + return + } + seller := dc.ArgStr(1, 128) + amount := dc.GetU64("amount:" + id) + dc.Transfer(dc.Treasury(), seller, amount) + dc.Log("released: " + id) +} +``` + +## Сборка + +```bash +cd contracts/mycontract + +# Собрать WASM +tinygo build -o mycontract.wasm -target wasip1 -no-debug . + +# Проверить размер +ls -lh mycontract.wasm + +# Опционально: wasm-strip для уменьшения размера +wasm-strip mycontract.wasm +``` + +Флаги: +- `-target wasip1` — WASI Preview 1 (единственный поддерживаемый target) +- `-no-debug` — убрать debug info из WASM (уменьшает размер) +- `-opt=2` — агрессивная оптимизация (по умолчанию в release) + +## Деплой + +```bash +CONTRACT_ID=$(client deploy-contract \ + --key /keys/node1.json \ + --wasm mycontract.wasm \ + --abi mycontract_abi.json \ + --node http://localhost:8081 | grep contract_id | awk '{print $2}') + +echo "Contract: $CONTRACT_ID" +``` + +## Проверка WASM + +Перед деплоем можно проверить что модуль валиден: + +```go +// В Go тесте +ctx := context.Background() +v := vm.NewVM(ctx) +defer v.Close(ctx) + +wasmBytes, _ := os.ReadFile("mycontract.wasm") +if err := v.Validate(ctx, wasmBytes); err != nil { + log.Fatal(err) +} +``` + +## Пример: hello_go + +Полный пример TinyGo-контракта с комментариями находится в [`contracts/hello_go/main.go`](../../contracts/hello_go/main.go). + +Демонстрирует: +- `increment` / `get` / `reset` — счётчик с owner ACL +- `greet` — строковые аргументы +- `ping` — межконтрактный вызов + +```bash +# Собрать +cd contracts/hello_go +tinygo build -o hello_go.wasm -target wasip1 -no-debug . + +# Задеплоить +docker exec node1 client deploy-contract \ + --key /keys/node1.json \ + --wasm hello_go.wasm \ + --abi hello_go_abi.json \ + --node http://node1:8080 + +# Вызвать +docker exec node1 client call-contract \ + --key /keys/node1.json --contract $HELLO_ID \ + --method increment --gas 5000 --node http://node1:8080 + +docker exec node1 client call-contract \ + --key /keys/node1.json --contract $HELLO_ID \ + --method greet --arg "DChain" --gas 5000 --node http://node1:8080 +``` + +## Ограничения TinyGo в контекcте WASM + +- Нет `goroutine` в WASM (нет многопоточности) +- Нет `net`, `os`, `syscall` — контракт изолирован +- Нет доступа к файловой системе и сети +- `fmt.Println` и `log.Printf` работают через WASI stdout — **но не логируются в blockchain**. + Используйте только `dc.Log()` для логов видимых в Explorer. +- Размер памяти по умолчанию: 64 KB на stack + heap управляется TinyGo GC diff --git a/docs/node/README.md b/docs/node/README.md new file mode 100644 index 0000000..fa5db72 --- /dev/null +++ b/docs/node/README.md @@ -0,0 +1,219 @@ +# Запуск ноды + +## Быстрый старт (Docker) + +```bash +git clone https://github.com/your/go-blockchain +cd go-blockchain +docker compose up -d +``` + +Запускает 3 ноды: `node1` (8081), `node2` (8082), `node3` (8083). + +Деплой контрактов: +```bash +docker exec node1 /scripts/deploy_contracts.sh +``` + +Explorer: http://localhost:8081 + +--- + +## Запуск вручную + +### Требования + +- Go 1.21+ +- BadgerDB (встроен) +- libp2p (встроен) + +### Сборка + +```bash +cd go-blockchain +go build ./cmd/node/ +go build ./cmd/client/ +``` + +### Genesis нода + +```bash +# Первый запуск — создать genesis +./node \ + --key node1.json \ + --genesis \ + --validators "$(cat node1.json | jq -r .pub_key),$(cat node2.json | jq -r .pub_key),$(cat node3.json | jq -r .pub_key)" \ + --stats-addr :8081 \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --db ./data/node1 +``` + +### Peer нода + +```bash +./node \ + --key node2.json \ + --peers /ip4/127.0.0.1/tcp/4001/p2p/<node1-peer-id> \ + --stats-addr :8082 \ + --listen /ip4/0.0.0.0/tcp/4002 \ + --db ./data/node2 +``` + +--- + +## Флаги командной строки + +| Флаг | По умолчанию | Описание | +|------|------------|---------| +| `--key` | `node.json` | Файл Ed25519 + X25519 идентичности | +| `--db` | `chaindata` | Директория BadgerDB | +| `--listen` | `/ip4/0.0.0.0/tcp/4001` | libp2p адрес | +| `--peers` | — | Bootstrap peer multiaddrs (через запятую) | +| `--validators` | — | Pubkeys валидаторов (через запятую, только для `--genesis`) | +| `--genesis` | false | Создать genesis блок при первом старте | +| `--stats-addr` | `:8080` | HTTP API/Explorer порт | +| `--wallet` | — | Payout кошелёк (hex pubkey) | +| `--wallet-pass` | — | Пароль к кошельку | +| `--heartbeat` | false | Отправлять heartbeat каждые 60 минут | +| `--register-relay` | false | Зарегистрироваться как relay-провайдер | +| `--relay-fee` | 0 | Fee за relay сообщение в µT | +| `--relay-key` | `relay.json` | X25519 ключ для relay шифрования | +| `--mailbox-db` | — | Директория для relay mailbox | +| `--governance-contract` | — | ID governance контракта для динамических параметров | + +--- + +## Docker Compose + +### docker-compose.yml структура + +```yaml +services: + node1: + build: . + ports: + - "8081:8080" # HTTP API + - "4001:4001" # libp2p + volumes: + - node1_data:/chaindata + - ./keys:/keys:ro + command: > + node + --key /keys/node1.json + --genesis + --validators "$VALIDATORS" + --stats-addr :8080 + --listen /ip4/0.0.0.0/tcp/4001 + --governance-contract "$GOV_ID" + environment: + - VALIDATORS=03...,04...,05... + + node2: + build: . + ports: + - "8082:8080" + command: > + node + --key /keys/node2.json + --peers /dns4/node1/tcp/4001/p2p/$NODE1_PEER_ID + --stats-addr :8080 + --listen /ip4/0.0.0.0/tcp/4001 +``` + +### Управление + +```bash +# Запустить +docker compose up -d + +# Логи +docker compose logs -f node1 + +# Остановить +docker compose down + +# Сбросить данные +docker compose down -v +``` + +--- + +## Файл ключа + +Каждая нода использует один файл с обоими ключами: + +```json +{ + "pub_key": "03abcd...", // Ed25519 pubkey (hex, 66 символов) + "priv_key": "...", // Ed25519 privkey (hex) + "x25519_pub": "...", // X25519 pubkey для E2E relay (hex, 64 символа) + "x25519_priv": "..." // X25519 privkey +} +``` + +Генерация: +```bash +client keygen --out node1.json +``` + +--- + +## Мониторинг + +### HTTP healthcheck + +```bash +curl http://localhost:8081/api/netstats +``` + +### Logs + +```bash +# Docker +docker compose logs -f node1 | grep -E "block|error|warn" + +# Прямой запуск +./node ... 2>&1 | tee node.log +``` + +### Метрики производительности + +| Показатель | Норма | +|-----------|------| +| Время блока | ~3 с | +| Блоков в минуту | ~20 | +| PBFT фазы | prepare → commit → finalize | + +--- + +## Структура данных BadgerDB + +``` +balance:<pubkey> → uint64 (µT) +identity:<pubkey> → JSON RegisterKeyPayload +stake:<pubkey> → uint64 (µT) +block:<index> → JSON Block +tx:<txid> → JSON TxRecord +txidx:<pubkey>:<block>:<seq> → txid (индекс по адресу) +contract:<id> → JSON ContractRecord +cstate:<id>:<key> → []byte (state контракта) +clog:<id>:<seq> → JSON ContractLogEntry +relay:<pubkey> → JSON RegisteredRelayInfo +``` + +--- + +## Сброс и восстановление + +```bash +# Полный сброс +docker compose down -v +docker compose up -d + +# Только данные одной ноды +docker compose stop node1 +docker volume rm go-blockchain_node1_data +docker compose up -d node1 +``` + +После сброса нужно заново задеплоить контракты и залинковать governance. diff --git a/docs/node/governance.md b/docs/node/governance.md new file mode 100644 index 0000000..3a58201 --- /dev/null +++ b/docs/node/governance.md @@ -0,0 +1,118 @@ +# Governance интеграция + +Нода может использовать governance-контракт для динамического управления параметрами (gas_price и другими) без перезапуска. + +## Принцип работы + +``` +Governance contract (on-chain) + state: param:gas_price = "5" + param:relay_fee = "200" + ... + +Node (blockchain/chain.go) + GetEffectiveGasPrice() → читает BadgerDB напрямую + GetGovParam(key) → cstate:<govID>:param:<key> +``` + +Нода читает параметры **напрямую из BadgerDB** без вызова VM — это быстро и дёшево. + +## Привязка governance + +### При запуске ноды (флаг) + +```bash +./node \ + --key node1.json \ + --governance-contract a1b2c3d4e5f60001 \ + ... +``` + +### В рантайме (без перезапуска) + +```bash +curl -X POST http://localhost:8081/api/governance/link \ + -H "Content-Type: application/json" \ + -d '{"governance": "a1b2c3d4e5f60001"}' +``` + +**Response:** +```json +{"status": "ok", "governance": "a1b2c3d4e5f60001"} +``` + +Изменение вступает в силу немедленно — следующий `CALL_CONTRACT` уже будет использовать новый governance. + +### Через deploy-скрипт (автоматически) + +`scripts/deploy_contracts.sh` автоматически вызывает `link_governance` на всех нодах после деплоя: + +```bash +link_governance() { + for NODE in http://node1:8080 http://node2:8080 http://node3:8080; do + curl -sX POST "$NODE/api/governance/link" \ + -H "Content-Type: application/json" \ + -d "{\"governance\":\"$GOV_ID\"}" + done +} +``` + +## Управляемые параметры + +| Ключ | Тип | По умолчанию | Описание | +|------|-----|-------------|---------| +| `gas_price` | uint64 (строка) | 1 | µT за 1 gas unit | + +Дополнительные параметры можно хранить в governance для читающих их контрактов (например, `messenger_entry_fee`, `relay_fee_override`, etc.) — нода их не читает, но они доступны через inter-contract вызовы. + +## Изменение gas_price + +```bash +# 1. Любой предлагает новое значение +client call-contract --method propose \ + --arg gas_price --arg 5 \ + --contract $GOV_ID --key key.json \ + --gas 10000 --node http://node1:8080 + +# 2. Admin утверждает +client call-contract --method approve \ + --arg gas_price \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 10000 --node http://node1:8080 + +# 3. Проверить текущее значение +curl "http://localhost:8081/api/contracts/$GOV_ID/state/param:gas_price" +``` + +После approve — изменение вступает в силу немедленно на всех нодах которые привязали этот governance. + +## Передача роли admin + +```bash +# Передать admin другому валидатору +client call-contract --method set_admin \ + --arg $NEW_ADMIN_PUBKEY \ + --contract $GOV_ID --key /keys/node1.json \ + --gas 10000 --node http://node1:8080 +``` + +## Отвязать governance + +Если нужно вернуться к значениям по умолчанию: + +```bash +curl -X POST http://localhost:8081/api/governance/link \ + -H "Content-Type: application/json" \ + -d '{"governance": ""}' +``` + +После этого нода будет использовать встроенные константы (`GasPrice = 1 µT/gas`). + +## Проверка привязки + +Прямого API для проверки текущего govContractID нет. Косвенно: если gas_price изменился через governance и применился — привязка работает. + +```bash +# Установить gas_price = 1 через governance +# Затем вызвать контракт с --gas 1000 и проверить fee = 1000 µT +``` diff --git a/docs/node/multi-server.md b/docs/node/multi-server.md new file mode 100644 index 0000000..07de38b --- /dev/null +++ b/docs/node/multi-server.md @@ -0,0 +1,291 @@ +# Деплой на реальные серверы + +Руководство по запуску трёх полноценных нод на разных VPS/серверах в интернете. + +## Концепция + +``` + Server A (1.2.3.11) Server B (1.2.3.12) Server C (1.2.3.13) + ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ + │ node1 │ │ node2 │ │ node3 │ + │ validator │◀────────▶│ validator │◀────────▶│ validator │ + │ relay (2000µT) │ │ relay (1500µT) │ │ relay (1000µT) │ + │ :4001 :8080 │ │ :4001 :8080 │ │ :4001 :8080 │ + └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └────────────────────────────┴─────────────────────────────┘ + libp2p P2P + gossipsub: tx + blocks + streams: PBFT consensus, sync +``` + +Каждая нода — полноценный **валидатор** (участвует в PBFT-консенсусе) и **relay-провайдер** (хранит и доставляет зашифрованные сообщения). + +## Требования + +- Ubuntu 22.04 / Debian 12 (или любой Linux) +- Открытый TCP-порт `4001` (P2P) и опционально `8080` (HTTP API) +- Go 1.21+ **только для сборки** (на сервере нужен только бинарник) + +## 1. Подготовка ключей + +На любой машине (ноутбук / CI): + +```bash +# Сгенерировать 3 ключа +./client keygen --out keys/node1.json +./client keygen --out keys/node2.json +./client keygen --out keys/node3.json + +# Получить pubkey и peer ID для каждого +./peerid --key keys/node1.json --ip <SERVER_A_IP> --port 4001 +./peerid --key keys/node2.json --ip <SERVER_B_IP> --port 4001 +./peerid --key keys/node3.json --ip <SERVER_C_IP> --port 4001 +``` + +Вывод `peerid`: +``` +pub_key: 26018d40... +peer_id: 12D3KooW... +multiaddr: /ip4/1.2.3.11/tcp/4001/p2p/12D3KooW... +``` + +Запишите `pub_key` и `peer_id` для каждой ноды — они нужны в конфигурации. + +## 2. Сборка бинарника + +```bash +git clone https://github.com/your/go-blockchain +cd go-blockchain + +CGO_ENABLED=0 GOOS=linux go build -trimpath -o node ./cmd/node +CGO_ENABLED=0 GOOS=linux go build -trimpath -o client ./cmd/client + +# Скопировать на серверы +scp node client root@1.2.3.11:/usr/local/bin/ +scp node client root@1.2.3.12:/usr/local/bin/ +scp node client root@1.2.3.13:/usr/local/bin/ +``` + +## 3. Копирование ключей + +```bash +# Каждый сервер получает ТОЛЬКО свой ключ +scp keys/node1.json root@1.2.3.11:/etc/dchain/node.json +scp keys/node2.json root@1.2.3.12:/etc/dchain/node.json +scp keys/node3.json root@1.2.3.13:/etc/dchain/node.json + +chmod 600 /etc/dchain/node.json # на каждом сервере +``` + +## 4. Переменные конфигурации + +Подставьте реальные значения из шага 1: + +```bash +# Validators — все три pubkey через запятую +VALIDATORS="<NODE1_PUB>,<NODE2_PUB>,<NODE3_PUB>" + +# Bootstrap multiaddrs для node2 и node3 +NODE1_PEER="/ip4/1.2.3.11/tcp/4001/p2p/<NODE1_PEER_ID>" +NODE2_PEER="/ip4/1.2.3.12/tcp/4001/p2p/<NODE2_PEER_ID>" +``` + +## 5. Запуск нод + +### Server A — node1 (genesis + validator + relay) + +```bash +node \ + --genesis \ + --key /etc/dchain/node.json \ + --db /var/lib/dchain/chain \ + --mailbox-db /var/lib/dchain/mailbox \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --announce /ip4/1.2.3.11/tcp/4001 \ + --stats-addr :8080 \ + --validators "$VALIDATORS" \ + --heartbeat=true \ + --register-relay \ + --relay-fee 2000 +``` + +### Server B — node2 (validator + relay) + +```bash +node \ + --key /etc/dchain/node.json \ + --db /var/lib/dchain/chain \ + --mailbox-db /var/lib/dchain/mailbox \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --announce /ip4/1.2.3.12/tcp/4001 \ + --stats-addr :8080 \ + --validators "$VALIDATORS" \ + --peers "$NODE1_PEER" \ + --heartbeat=true \ + --register-relay \ + --relay-fee 1500 +``` + +### Server C — node3 (validator + relay) + +```bash +node \ + --key /etc/dchain/node.json \ + --db /var/lib/dchain/chain \ + --mailbox-db /var/lib/dchain/mailbox \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --announce /ip4/1.2.3.13/tcp/4001 \ + --stats-addr :8080 \ + --validators "$VALIDATORS" \ + --peers "$NODE1_PEER,$NODE2_PEER" \ + --heartbeat=true \ + --register-relay \ + --relay-fee 1000 +``` + +## 6. systemd unit + +Создайте `/etc/systemd/system/dchain.service` на каждом сервере: + +```ini +[Unit] +Description=DChain Node +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=dchain +ExecStart=/usr/local/bin/node \ + --key /etc/dchain/node.json \ + --db /var/lib/dchain/chain \ + --mailbox-db /var/lib/dchain/mailbox \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --announce /ip4/YOUR_PUBLIC_IP/tcp/4001 \ + --stats-addr :8080 \ + --validators "V1_PUB,V2_PUB,V3_PUB" \ + --peers "/ip4/SEED_IP/tcp/4001/p2p/SEED_PEER_ID" \ + --heartbeat=true \ + --register-relay \ + --relay-fee 1000 +Restart=on-failure +RestartSec=5s +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Применить +sudo systemctl daemon-reload +sudo systemctl enable --now dchain + +# Логи +sudo journalctl -u dchain -f +``` + +## 7. Деплой контрактов + +После запуска всех трёх нод: + +```bash +# С любой машины имеющей client и ключ node1 +export NODE_URL=http://1.2.3.11:8080 +export KEY=/etc/dchain/node.json # или локальная копия + +# Задеплоить username_registry +CONTRACT_ID=$(client deploy-contract \ + --key $KEY \ + --wasm contracts/username_registry/username_registry.wasm \ + --abi contracts/username_registry/username_registry_abi.json \ + --node $NODE_URL | grep contract_id | awk '{print $2}') + +echo "username_registry: $CONTRACT_ID" + +# Залинковать governance на всех трёх нодах +for NODE in http://1.2.3.11:8080 http://1.2.3.12:8080 http://1.2.3.13:8080; do + curl -sX POST "$NODE/api/governance/link" \ + -H "Content-Type: application/json" \ + -d "{\"governance\":\"$GOV_ID\"}" +done +``` + +## 8. Как работает сеть + +### Bootstrap и discovery + +``` +node2 старт: + 1. --peers → connectWithRetry("/ip4/1.2.3.11/tcp/4001/p2p/...") + Подключается к node1 (бесконечный retry с backoff 1→30s) + + 2. При connect → syncOnConnect() + Запрашивает все блоки которых нет у node2 + + 3. Kademlia DHT bootstrap (через node1) + DHT узнаёт о node3 → DiscoverPeers() подключается + + 4. mDNS (только LAN/Docker) — игнорируется в интернете + + 5. connectWithRetry keep-alive: каждые 30s проверяет связь, + автоматически переподключает при обрыве +``` + +### Консенсус + +PBFT 3-of-3, fault tolerance f=1: +- Для коммита блока нужны подписи **2 из 3** валидаторов +- Если одна нода упала → сеть продолжает работать +- Если упали две → сеть ждёт (не производит блоки) + +### --announce и адреса + +Без `--announce` libp2p рекламирует **все** адреса интерфейсов: +- `0.0.0.0` → раскрывается в loopback и внутренние адреса +- Другие ноды получают `127.0.0.1:4001` → не могут подключиться + +С `--announce /ip4/1.2.3.11/tcp/4001`: +- Единственный рекламируемый адрес — публичный IP сервера +- `AddrStrings()` возвращает только этот адрес +- DHT propagates этот адрес другим нодам в сети + +## 9. Firewall + +```bash +# UFW +ufw allow 4001/tcp # P2P — обязательно +ufw allow 8080/tcp # HTTP API — опционально (для Explorer, деплоя контрактов) + +# iptables +iptables -A INPUT -p tcp --dport 4001 -j ACCEPT +iptables -A INPUT -p tcp --dport 8080 -j ACCEPT +``` + +## 10. Добавление новой ноды в сеть + +Новый участник (не валидатор, только синхронизация и relay): + +```bash +node \ + --key /etc/dchain/node.json \ + --db /var/lib/dchain/chain \ + --mailbox-db /var/lib/dchain/mailbox \ + --listen /ip4/0.0.0.0/tcp/4001 \ + --announce /ip4/YOUR_IP/tcp/4001 \ + --stats-addr :8080 \ + --validators "$VALIDATORS" \ + --peers "$NODE1_PEER" \ + --heartbeat=false \ + --register-relay \ + --relay-fee 500 +``` + +Нода автоматически: +1. Подключится к node1 через `--peers` +2. Синхронизирует всю историю блоков +3. Через DHT найдёт node2 и node3 +4. Начнёт получать и пересылать relay-сообщения + +Чтобы сделать её валидатором — нужна `ADD_VALIDATOR` транзакция от существующего валидатора. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..04d3f58 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,177 @@ +# Быстрый старт + +## Требования + +- Docker Desktop (или Docker Engine + Compose v2) +- 4 GB RAM, 2 CPU + +Для разработки контрактов дополнительно: +- Go 1.21+ +- TinyGo 0.30+ (только для TinyGo-контрактов) + +--- + +## 1. Запустить сеть + +```bash +git clone <repo> +cd go-blockchain + +docker compose up --build -d +``` + +Запускается три ноды: + +| Контейнер | Роль | Explorer | +|-----------|------|---------| +| node1 | genesis + validator + relay | http://localhost:8081 | +| node2 | validator + relay | http://localhost:8082 | +| node3 | relay-only observer | http://localhost:8083 | + +Дождитесь пока в Explorer появятся блоки (~10 секунд). + +Swagger: http://localhost:8081/swagger + +--- + +## 2. Задеплоить контракты + +```bash +docker compose --profile deploy run --rm deploy +``` + +Скрипт: +1. Ждёт готовности node1 +2. Деплоит 4 контракта из genesis-ключа `/keys/node1.json` +3. Вызывает `init` на governance и escrow +4. Привязывает governance к нодам через `/api/governance/link` +5. Выводит contract ID и сохраняет в `/tmp/contracts.env` + +Пример вывода: +``` +══════════════════════════════════════════════════ + DChain — деплой production-контрактов +══════════════════════════════════════════════════ + +▶ Деплой username_registry + ✓ username_registry contract_id: a1b2c3d4e5f60718 + +▶ Деплой governance + ✓ governance contract_id: 9f8e7d6c5b4a3210 + +▶ Деплой auction + ✓ auction contract_id: 1a2b3c4d5e6f7089 + +▶ Деплой escrow + ✓ escrow contract_id: fedcba9876543210 + + ✓ username_registry : a1b2c3d4e5f60718 + ✓ governance : 9f8e7d6c5b4a3210 + ✓ auction : 1a2b3c4d5e6f7089 + ✓ escrow : fedcba9876543210 +``` + +Сохраните ID для последующего использования: +```bash +# Запомнить ID +export UR_ID=a1b2c3d4e5f60718 +export GOV_ID=9f8e7d6c5b4a3210 +export AUC_ID=1a2b3c4d5e6f7089 +export ESC_ID=fedcba9876543210 +``` + +--- + +## 3. Первые операции + +### Проверить баланс genesis-кошелька + +```bash +docker exec node1 client balance \ + --key /keys/node1.json \ + --node http://node1:8080 +``` + +### Создать новый кошелёк + +```bash +docker exec node1 wallet keygen --out /tmp/alice.json +docker exec node1 client balance \ + --key /tmp/alice.json \ + --node http://node1:8080 +``` + +### Перевести токены + +```bash +# Получить pubkey Alice +ALICE_PUB=$(docker exec node1 sh -c 'cat /tmp/alice.json | grep pub_key' | grep -oP '"pub_key":\s*"\K[^"]+') + +docker exec node1 client transfer \ + --key /keys/node1.json \ + --to $ALICE_PUB \ + --amount 1000000 \ + --node http://node1:8080 +``` + +### Зарегистрировать username + +```bash +docker exec node1 client call-contract \ + --key /keys/node1.json \ + --contract $UR_ID \ + --method register \ + --arg alice \ + --gas 20000 \ + --node http://node1:8080 +``` + +### Отправить сообщение (по username) + +```bash +docker exec node1 client send-msg \ + --key /keys/node1.json \ + --to @alice \ + --registry $UR_ID \ + --msg "Привет!" \ + --node http://node1:8080 +``` + +--- + +## 4. Explorer + +После деплоя контракты видны в Explorer: + +``` +http://localhost:8081/contracts — все контракты +http://localhost:8081/contract?id=$UR_ID — username_registry +http://localhost:8081/contract?id=$GOV_ID — governance +http://localhost:8081/contract?id=$AUC_ID — auction +http://localhost:8081/contract?id=$ESC_ID — escrow +``` + +Вкладки в Explorer на странице контракта: +- **Overview** — метаданные, ABI-методы +- **State** — query state по ключу +- **Logs** — history вызовов с логами +- **Raw** — сырой JSON ContractRecord + +--- + +## 5. Полный сброс + +```bash +docker compose down -v && docker compose up --build -d +``` + +Флаг `-v` удаляет тома BadgerDB. После пересборки сеть стартует с чистого genesis. + +--- + +## Следующие шаги + +- [Контракты](contracts/README.md) — использование всех 4 контрактов +- [Разработка контрактов](development/README.md) — написать свой контракт +- [CLI](cli/README.md) — все команды клиента +- [API](api/README.md) — REST-интерфейс diff --git a/economy/rewards.go b/economy/rewards.go new file mode 100644 index 0000000..beb151d --- /dev/null +++ b/economy/rewards.go @@ -0,0 +1,71 @@ +// Package economy contains helpers for computing and formatting token rewards. +package economy + +import ( + "fmt" + + "go-blockchain/blockchain" +) + +// BlockFeeBreakdown describes the fee income for a committed block validator. +// There is no minting — validators earn only the fees paid by transactions. +type BlockFeeBreakdown struct { + ValidatorPubKey string + BlockIndex uint64 + TxFees uint64 // sum of all transaction fees in the block + IsGenesis bool // true for block 0 (genesis allocation, not fees) + GenesisAmount uint64 +} + +// ComputeBlockReward calculates the fee income for a committed block. +func ComputeBlockReward(b *blockchain.Block) BlockFeeBreakdown { + d := BlockFeeBreakdown{ + ValidatorPubKey: b.Validator, + BlockIndex: b.Index, + TxFees: b.TotalFees, + } + if b.Index == 0 { + d.IsGenesis = true + d.GenesisAmount = blockchain.GenesisAllocation + } + return d +} + +// Total returns how much the validator actually receives for this block. +func (d BlockFeeBreakdown) Total() uint64 { + if d.IsGenesis { + return d.GenesisAmount + } + return d.TxFees +} + +// Summary prints a human-readable reward summary. +func (d BlockFeeBreakdown) Summary() string { + if d.IsGenesis { + return fmt.Sprintf( + "Block #0 (genesis): validator %s...%s receives genesis allocation %s", + d.ValidatorPubKey[:8], d.ValidatorPubKey[len(d.ValidatorPubKey)-4:], + FormatTokens(d.GenesisAmount), + ) + } + if d.TxFees == 0 { + return fmt.Sprintf( + "Block #%d committed: validator %s...%s — no fees", + d.BlockIndex, + d.ValidatorPubKey[:8], d.ValidatorPubKey[len(d.ValidatorPubKey)-4:], + ) + } + return fmt.Sprintf( + "Block #%d committed: validator %s...%s earns %s in fees", + d.BlockIndex, + d.ValidatorPubKey[:8], d.ValidatorPubKey[len(d.ValidatorPubKey)-4:], + FormatTokens(d.TxFees), + ) +} + +// FormatTokens converts micro-tokens to a human-readable string. +func FormatTokens(microTokens uint64) string { + whole := microTokens / blockchain.Token + frac := microTokens % blockchain.Token + return fmt.Sprintf("%d.%06d T", whole, frac) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d145dd --- /dev/null +++ b/go.mod @@ -0,0 +1,126 @@ +module go-blockchain + +go 1.21 + +require ( + github.com/dgraph-io/badger/v4 v4.2.0 + github.com/libp2p/go-libp2p v0.32.2 + github.com/libp2p/go-libp2p-kad-dht v0.25.2 + github.com/libp2p/go-libp2p-pubsub v0.10.0 + github.com/multiformats/go-multiaddr v0.12.3 + github.com/tetratelabs/wazero v1.7.3 + golang.org/x/crypto v0.18.0 +) + +require ( + github.com/benbjohnson/clock v1.3.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/containerd/cgroups v1.1.0 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/dgraph-io/ristretto v0.1.1 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect + github.com/elastic/gosigar v0.14.2 // indirect + github.com/flynn/noise v1.0.0 // indirect + github.com/francoispqt/gojay v1.2.13 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v1.0.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/snappy v0.0.3 // indirect + github.com/google/flatbuffers v1.12.1 // indirect + github.com/google/gopacket v1.1.19 // indirect + github.com/google/pprof v0.0.0-20231023181126-ff6d637d2a7b // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/gorilla/websocket v1.5.0 + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/ipfs/boxo v0.10.0 // indirect + github.com/ipfs/go-cid v0.4.1 // indirect + github.com/ipfs/go-datastore v0.6.0 // indirect + github.com/ipfs/go-log v1.0.5 // indirect + github.com/ipfs/go-log/v2 v2.5.1 // indirect + github.com/ipld/go-ipld-prime v0.20.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect + github.com/jbenet/goprocess v0.1.4 // indirect + github.com/klauspost/compress v1.17.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect + github.com/libp2p/go-buffer-pool v0.1.0 // indirect + github.com/libp2p/go-cidranger v1.1.0 // indirect + github.com/libp2p/go-flow-metrics v0.1.0 // indirect + github.com/libp2p/go-libp2p-asn-util v0.3.0 // indirect + github.com/libp2p/go-libp2p-kbucket v0.6.3 // indirect + github.com/libp2p/go-libp2p-record v0.2.0 // indirect + github.com/libp2p/go-libp2p-routing-helpers v0.7.2 // indirect + github.com/libp2p/go-msgio v0.3.0 // indirect + github.com/libp2p/go-nat v0.2.0 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect + github.com/libp2p/go-reuseport v0.4.0 // indirect + github.com/libp2p/go-yamux/v4 v4.0.1 // indirect + github.com/libp2p/zeroconf/v2 v2.2.0 // indirect + github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/miekg/dns v1.1.56 // indirect + github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect + github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect + github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/multiformats/go-base32 v0.1.0 // indirect + github.com/multiformats/go-base36 v0.2.0 // indirect + github.com/multiformats/go-multiaddr-dns v0.3.1 // indirect + github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multicodec v0.9.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-multistream v0.5.0 // indirect + github.com/multiformats/go-varint v0.0.7 // indirect + github.com/onsi/ginkgo/v2 v2.13.0 // indirect + github.com/opencontainers/runtime-spec v1.1.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/polydawn/refmt v0.89.0 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/quic-go/qpack v0.4.0 // indirect + github.com/quic-go/qtls-go1-20 v0.3.4 // indirect + github.com/quic-go/quic-go v0.39.4 // indirect + github.com/quic-go/webtransport-go v0.6.0 // indirect + github.com/raulk/go-watchdog v1.3.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/otel v1.16.0 // indirect + go.opentelemetry.io/otel/metric v1.16.0 // indirect + go.opentelemetry.io/otel/trace v1.16.0 // indirect + go.uber.org/dig v1.17.1 // indirect + go.uber.org/fx v1.20.1 // indirect + go.uber.org/mock v0.3.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sync v0.4.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.14.0 // indirect + gonum.org/v1/gonum v0.13.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + lukechampine.com/blake3 v1.2.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f732b3b --- /dev/null +++ b/go.sum @@ -0,0 +1,615 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= +dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= +dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= +dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= +dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= +github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/ebpf v0.2.0/go.mod h1:To2CFviqOWL/M0gIMsvSMlqe7em/l1ALkX1PyjrX2Qs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/containerd/cgroups v0.0.0-20201119153540-4cbc285b3327/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE= +github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= +github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= +github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU= +github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= +github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= +github.com/elastic/gosigar v0.14.2 h1:Dg80n8cr90OZ7x+bAax/QjoW/XqTI11RmA79ZwIm9/4= +github.com/elastic/gosigar v0.14.2/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/flynn/noise v1.0.0 h1:DlTHqmzmvcEiKj+4RYo/imoswx/4r6iBlCMfVtrMXpQ= +github.com/flynn/noise v1.0.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= +github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= +github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20231023181126-ff6d637d2a7b h1:RMpPgZTSApbPf7xaVel+QkoGPRLFLrwFO89uDUHEGf0= +github.com/google/pprof v0.0.0-20231023181126-ff6d637d2a7b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= +github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= +github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/ipfs/boxo v0.10.0 h1:tdDAxq8jrsbRkYoF+5Rcqyeb91hgWe2hp7iLu7ORZLY= +github.com/ipfs/boxo v0.10.0/go.mod h1:Fg+BnfxZ0RPzR0nOodzdIq3A7KgoWAOWsEIImrIQdBM= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= +github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= +github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= +github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= +github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= +github.com/ipfs/go-ipfs-util v0.0.2 h1:59Sswnk1MFaiq+VcaknX7aYEyGyGDAA73ilhEK2POp8= +github.com/ipfs/go-ipfs-util v0.0.2/go.mod h1:CbPtkWJzjLdEcezDns2XYaehFVNXG9zrdrtMecczcsQ= +github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= +github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= +github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= +github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= +github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= +github.com/ipld/go-ipld-prime v0.20.0 h1:Ud3VwE9ClxpO2LkCYP7vWPc0Fo+dYdYzgxUJZ3uRG4g= +github.com/ipld/go-ipld-prime v0.20.0/go.mod h1:PzqZ/ZR981eKbgdr3y2DJYeD/8bgMawdGVlJDE8kK+M= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= +github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= +github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= +github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= +github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= +github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= +github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= +github.com/libp2p/go-cidranger v1.1.0 h1:ewPN8EZ0dd1LSnrtuwd4709PXVcITVeuwbag38yPW7c= +github.com/libp2p/go-cidranger v1.1.0/go.mod h1:KWZTfSr+r9qEo9OkI9/SIEeAtw+NNoU0dXIXt15Okic= +github.com/libp2p/go-flow-metrics v0.1.0 h1:0iPhMI8PskQwzh57jB9WxIuIOQ0r+15PChFGkx3Q3WM= +github.com/libp2p/go-flow-metrics v0.1.0/go.mod h1:4Xi8MX8wj5aWNDAZttg6UPmc0ZrnFNsMtpsYUClFtro= +github.com/libp2p/go-libp2p v0.32.2 h1:s8GYN4YJzgUoyeYNPdW7JZeZ5Ee31iNaIBfGYMAY4FQ= +github.com/libp2p/go-libp2p v0.32.2/go.mod h1:E0LKe+diV/ZVJVnOJby8VC5xzHF0660osg71skcxJvk= +github.com/libp2p/go-libp2p-asn-util v0.3.0 h1:gMDcMyYiZKkocGXDQ5nsUQyquC9+H+iLEQHwOCZ7s8s= +github.com/libp2p/go-libp2p-asn-util v0.3.0/go.mod h1:B1mcOrKUE35Xq/ASTmQ4tN3LNzVVaMNmq2NACuqyB9w= +github.com/libp2p/go-libp2p-kad-dht v0.25.2 h1:FOIk9gHoe4YRWXTu8SY9Z1d0RILol0TrtApsMDPjAVQ= +github.com/libp2p/go-libp2p-kad-dht v0.25.2/go.mod h1:6za56ncRHYXX4Nc2vn8z7CZK0P4QiMcrn77acKLM2Oo= +github.com/libp2p/go-libp2p-kbucket v0.6.3 h1:p507271wWzpy2f1XxPzCQG9NiN6R6lHL9GiSErbQQo0= +github.com/libp2p/go-libp2p-kbucket v0.6.3/go.mod h1:RCseT7AH6eJWxxk2ol03xtP9pEHetYSPXOaJnOiD8i0= +github.com/libp2p/go-libp2p-pubsub v0.10.0 h1:wS0S5FlISavMaAbxyQn3dxMOe2eegMfswM471RuHJwA= +github.com/libp2p/go-libp2p-pubsub v0.10.0/go.mod h1:1OxbaT/pFRO5h+Dpze8hdHQ63R0ke55XTs6b6NwLLkw= +github.com/libp2p/go-libp2p-record v0.2.0 h1:oiNUOCWno2BFuxt3my4i1frNrt7PerzB3queqa1NkQ0= +github.com/libp2p/go-libp2p-record v0.2.0/go.mod h1:I+3zMkvvg5m2OcSdoL0KPljyJyvNDFGKX7QdlpYUcwk= +github.com/libp2p/go-libp2p-routing-helpers v0.7.2 h1:xJMFyhQ3Iuqnk9Q2dYE1eUTzsah7NLw3Qs2zjUV78T0= +github.com/libp2p/go-libp2p-routing-helpers v0.7.2/go.mod h1:cN4mJAD/7zfPKXBcs9ze31JGYAZgzdABEm+q/hkswb8= +github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= +github.com/libp2p/go-libp2p-testing v0.12.0/go.mod h1:KcGDRXyN7sQCllucn1cOOS+Dmm7ujhfEyXQL5lvkcPg= +github.com/libp2p/go-msgio v0.3.0 h1:mf3Z8B1xcFN314sWX+2vOTShIE0Mmn2TXn3YCUQGNj0= +github.com/libp2p/go-msgio v0.3.0/go.mod h1:nyRM819GmVaF9LX3l03RMh10QdOroF++NBbxAb0mmDM= +github.com/libp2p/go-nat v0.2.0 h1:Tyz+bUFAYqGyJ/ppPPymMGbIgNRH+WqC5QrT5fKrrGk= +github.com/libp2p/go-nat v0.2.0/go.mod h1:3MJr+GRpRkyT65EpVPBstXLvOlAPzUVlG6Pwg9ohLJk= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= +github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= +github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= +github.com/libp2p/go-yamux/v4 v4.0.1 h1:FfDR4S1wj6Bw2Pqbc8Uz7pCxeRBPbwsBbEdfwiCypkQ= +github.com/libp2p/go-yamux/v4 v4.0.1/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4= +github.com/libp2p/zeroconf/v2 v2.2.0 h1:Cup06Jv6u81HLhIj1KasuNM/RHHrJ8T7wOTS4+Tv53Q= +github.com/libp2p/zeroconf/v2 v2.2.0/go.mod h1:fuJqLnUwZTshS3U/bMRJ3+ow/v9oid1n0DmyYyNO1Xs= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= +github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= +github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= +github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8= +github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms= +github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= +github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b/go.mod h1:lxPUiZwKoFL8DUUmalo2yJJUCxbPKtm8OKfqr2/FTNU= +github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc h1:PTfri+PuQmWDqERdnNMiD9ZejrlswWrCpBEZgWOiTrc= +github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc/go.mod h1:cGKTAVKx4SxOuR/czcZ/E2RSJ3sfHs8FpHhQ5CWMf9s= +github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= +github.com/minio/sha256-simd v0.1.1-0.20190913151208-6de447530771/go.mod h1:B5e1o+1/KgNmWrSQK08Y6Z1Vb5pwIktudl0J58iy0KM= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo= +github.com/multiformats/go-multiaddr v0.2.0/go.mod h1:0nO36NvPpyV4QzvTLi/lafl2y95ncPj0vFwVF6k6wJ4= +github.com/multiformats/go-multiaddr v0.12.3 h1:hVBXvPRcKG0w80VinQ23P5t7czWgg65BmIvQKjDydU8= +github.com/multiformats/go-multiaddr v0.12.3/go.mod h1:sBXrNzucqkFJhvKOiwwLyqamGa/P5EIXNPLovyhQCII= +github.com/multiformats/go-multiaddr-dns v0.3.1 h1:QgQgR+LQVt3NPTjbrLLpsaT2ufAA2y0Mkk+QRVJbW3A= +github.com/multiformats/go-multiaddr-dns v0.3.1/go.mod h1:G/245BRQ6FJGmryJCrOuTdB37AMA5AMOVuO6NY3JwTk= +github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= +github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= +github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= +github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-multistream v0.5.0 h1:5htLSLl7lvJk3xx3qT/8Zm9J4K8vEOf/QGkvOGQAyiE= +github.com/multiformats/go-multistream v0.5.0/go.mod h1:n6tMZiwiP2wUsR8DgfDWw1dydlEqV3l6N3/GBsX6ILA= +github.com/multiformats/go-varint v0.0.1/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= +github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg= +github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polydawn/refmt v0.89.0 h1:ADJTApkvkeBZsN0tBTx8QjpD9JkmxbKp0cxfr9qszm4= +github.com/polydawn/refmt v0.89.0/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= +github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= +github.com/quic-go/qtls-go1-20 v0.3.4 h1:MfFAPULvst4yoMgY9QmtpYmfij/em7O8UUi+bNVm7Cg= +github.com/quic-go/qtls-go1-20 v0.3.4/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k= +github.com/quic-go/quic-go v0.39.4 h1:PelfiuG7wXEffUT2yceiqz5V6Pc0TA5ruOd1LcmFc1s= +github.com/quic-go/quic-go v0.39.4/go.mod h1:T09QsDQWjLiQ74ZmacDfqZmhY/NLnw5BC40MANNNZ1Q= +github.com/quic-go/webtransport-go v0.6.0 h1:CvNsKqc4W2HljHJnoT+rMmbRJybShZ0YPFDD3NxaZLY= +github.com/quic-go/webtransport-go v0.6.0/go.mod h1:9KjU4AEBqEQidGHNDkZrb8CAa1abRaosM2yGOyiikEc= +github.com/raulk/go-watchdog v1.3.0 h1:oUmdlHxdkXRJlwfG0O9omj8ukerm8MEQavSiDTEtBsk= +github.com/raulk/go-watchdog v1.3.0/go.mod h1:fIvOnLbF0b0ZwkB9YU4mOW9Did//4vPZtDqv66NfsMU= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= +github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= +github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= +github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= +github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= +github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= +github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= +github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= +github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= +github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= +github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= +github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= +github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= +github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= +github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= +github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= +github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= +github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/tetratelabs/wazero v1.7.3 h1:PBH5KVahrt3S2AHgEjKu4u+LlDbbk+nsGE3KLucy6Rw= +github.com/tetratelabs/wazero v1.7.3/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y= +github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= +github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= +github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= +github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 h1:EKhdznlJHPMoKr0XTrX+IlJs1LH3lyx2nfr1dOlZ79k= +github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1/go.mod h1:8UvriyWtv5Q5EOgjHaSseUEdkQfvwFv1I/In/O2M9gc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/otel v1.16.0 h1:Z7GVAX/UkAXPKsy94IU+i6thsQS4nb7LviLpnaNeW8s= +go.opentelemetry.io/otel v1.16.0/go.mod h1:vl0h9NUa1D5s1nv3A5vZOYWn8av4K8Ml6JDeHrT/bx4= +go.opentelemetry.io/otel/metric v1.16.0 h1:RbrpwVG1Hfv85LgnZ7+txXioPDoh6EdbZHo26Q3hqOo= +go.opentelemetry.io/otel/metric v1.16.0/go.mod h1:QE47cpOmkwipPiefDwo2wDzwJrlfxxNYodqc4xnGCo4= +go.opentelemetry.io/otel/trace v1.16.0 h1:8JRpaObFoW0pxuVPapkgH8UhHQj+bJW8jJsCZEu5MQs= +go.opentelemetry.io/otel/trace v1.16.0/go.mod h1:Yt9vYq1SdNz3xdjZZK7wcXv1qv2pwLkqr2QVwea0ef0= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc= +go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk= +go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg= +go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= +go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= +go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= +golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20180810173357-98c5dad5d1a0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.13.0 h1:a0T3bh+7fhRyqeNbiC3qVHYmkiQgit3wnNan/2c0HMM= +gonum.org/v1/gonum v0.13.0/go.mod h1:/WPYRckkfWrhWefxyYTfrTtQR0KH4iyHNuzxqXAKyAU= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/identity/identity.go b/identity/identity.go new file mode 100644 index 0000000..ea2b3b2 --- /dev/null +++ b/identity/identity.go @@ -0,0 +1,245 @@ +package identity + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/sha512" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/nacl/box" + + "go-blockchain/blockchain" +) + +// Identity holds an Ed25519 keypair and a Curve25519 (X25519) keypair. +// Ed25519 is used for signing transactions and consensus messages. +// X25519 is used for NaCl box (E2E) message encryption. +type Identity struct { + PubKey ed25519.PublicKey + PrivKey ed25519.PrivateKey + + // X25519 keypair for NaCl box encryption. + // Generated together with Ed25519; stored alongside in key files. + X25519Pub [32]byte + X25519Priv [32]byte +} + +// Generate creates a fresh Ed25519 + X25519 keypair. +func Generate() (*Identity, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ed25519: %w", err) + } + xpub, xpriv, err := box.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate x25519: %w", err) + } + return &Identity{ + PubKey: pub, + PrivKey: priv, + X25519Pub: *xpub, + X25519Priv: *xpriv, + }, nil +} + +// PubKeyHex returns the hex-encoded Ed25519 public key. +func (id *Identity) PubKeyHex() string { + return hex.EncodeToString(id.PubKey) +} + +// PrivKeyHex returns the hex-encoded Ed25519 private key. +func (id *Identity) PrivKeyHex() string { + return hex.EncodeToString(id.PrivKey) +} + +// X25519PubHex returns the hex-encoded Curve25519 public key. +func (id *Identity) X25519PubHex() string { + return hex.EncodeToString(id.X25519Pub[:]) +} + +// X25519PrivHex returns the hex-encoded Curve25519 private key. +func (id *Identity) X25519PrivHex() string { + return hex.EncodeToString(id.X25519Priv[:]) +} + +// Sign returns an Ed25519 signature over msg. +func (id *Identity) Sign(msg []byte) []byte { + return ed25519.Sign(id.PrivKey, msg) +} + +// Verify returns true if sig is a valid Ed25519 signature over msg by pubKeyHex. +func Verify(pubKeyHex string, msg, sig []byte) (bool, error) { + pubBytes, err := hex.DecodeString(pubKeyHex) + if err != nil { + return false, fmt.Errorf("invalid pub key hex: %w", err) + } + return ed25519.Verify(ed25519.PublicKey(pubBytes), msg, sig), nil +} + +// MineRegistration performs a lightweight proof-of-work so that +// identity registration has a CPU cost (Sybil barrier). +func MineRegistration(pubKeyHex string, difficulty int) (nonce uint64, target string, err error) { + prefix := strings.Repeat("0", difficulty/4) + pubBytes, err := hex.DecodeString(pubKeyHex) + if err != nil { + return 0, "", err + } + buf := make([]byte, len(pubBytes)+8) + copy(buf, pubBytes) + for nonce = 0; ; nonce++ { + binary.BigEndian.PutUint64(buf[len(pubBytes):], nonce) + h := sha256.Sum256(buf) + hexHash := hex.EncodeToString(h[:]) + if strings.HasPrefix(hexHash, prefix) { + return nonce, hexHash, nil + } + } +} + +// RegisterTx builds and signs a REGISTER_KEY transaction. +// It includes the X25519 public key so recipients can look it up on-chain. +func RegisterTx(id *Identity, nickname string, powDifficulty int) (*blockchain.Transaction, error) { + nonce, target, err := MineRegistration(id.PubKeyHex(), powDifficulty) + if err != nil { + return nil, err + } + + payload := blockchain.RegisterKeyPayload{ + PubKey: id.PubKeyHex(), + Nickname: nickname, + PowNonce: nonce, + PowTarget: target, + X25519PubKey: id.X25519PubHex(), + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + tx := &blockchain.Transaction{ + ID: txID(id.PubKeyHex(), blockchain.EventRegisterKey), + Type: blockchain.EventRegisterKey, + From: id.PubKeyHex(), + Payload: payloadBytes, + Fee: blockchain.RegistrationFee, + Timestamp: time.Now().UTC(), + } + tx.Signature = id.Sign(txSignBytes(tx)) + return tx, nil +} + +// SignMessage returns a detached Ed25519 signature over an arbitrary message. +func (id *Identity) SignMessage(msg []byte) []byte { + return id.Sign(msg) +} + +// VerifyMessage verifies a detached signature (see SignMessage). +func VerifyMessage(pubKeyHex string, msg, sig []byte) (bool, error) { + return Verify(pubKeyHex, msg, sig) +} + +// FromHex reconstructs an Identity from hex-encoded Ed25519 keys. +// X25519 fields are left zeroed; use FromHexFull for complete identity. +func FromHex(pubHex, privHex string) (*Identity, error) { + return FromHexFull(pubHex, privHex, "", "") +} + +// deriveX25519 deterministically derives a Curve25519 keypair from an Ed25519 +// private key using the standard Ed25519→X25519 conversion (SHA-512 of seed + +// X25519 clamping). This matches libsodium's crypto_sign_ed25519_sk_to_curve25519. +func deriveX25519(privKey ed25519.PrivateKey) (pub [32]byte, priv [32]byte) { + // Ed25519 private key = seed (32 bytes) || public key (32 bytes). + seed := privKey[:32] + h := sha512.Sum512(seed) + // Apply X25519 scalar clamping. + h[0] &= 248 + h[31] &= 127 + h[31] |= 64 + copy(priv[:], h[:32]) + pubSlice, _ := curve25519.X25519(priv[:], curve25519.Basepoint) + copy(pub[:], pubSlice) + return pub, priv +} + +// FromHexFull reconstructs a complete Identity including X25519 keys. +// When x25519PubHex/x25519PrivHex are empty, X25519 is derived deterministically +// from the Ed25519 private key using the standard Ed25519→X25519 conversion. +func FromHexFull(pubHex, privHex, x25519PubHex, x25519PrivHex string) (*Identity, error) { + pubBytes, err := hex.DecodeString(pubHex) + if err != nil { + return nil, fmt.Errorf("decode pub key: %w", err) + } + privBytes, err := hex.DecodeString(privHex) + if err != nil { + return nil, fmt.Errorf("decode priv key: %w", err) + } + id := &Identity{ + PubKey: ed25519.PublicKey(pubBytes), + PrivKey: ed25519.PrivateKey(privBytes), + } + if x25519PubHex != "" && x25519PrivHex != "" { + b, err := hex.DecodeString(x25519PubHex) + if err != nil || len(b) != 32 { + return nil, fmt.Errorf("decode x25519 pub key: %w", err) + } + copy(id.X25519Pub[:], b) + b, err = hex.DecodeString(x25519PrivHex) + if err != nil || len(b) != 32 { + return nil, fmt.Errorf("decode x25519 priv key: %w", err) + } + copy(id.X25519Priv[:], b) + } else { + // Derive X25519 deterministically from Ed25519 private key. + id.X25519Pub, id.X25519Priv = deriveX25519(id.PrivKey) + } + return id, nil +} + +// txID generates a deterministic transaction ID. +func txID(fromPubHex string, eventType blockchain.EventType) string { + h := sha256.Sum256([]byte(fromPubHex + string(eventType) + fmt.Sprint(time.Now().UnixNano()))) + return hex.EncodeToString(h[:16]) +} + +// TxSignBytes returns the canonical bytes that must be signed (and verified) +// for a transaction. Use this whenever building a transaction outside of the +// identity package — signing json.Marshal(tx) instead is a common mistake +// that produces signatures VerifyTx will always reject. +func TxSignBytes(tx *blockchain.Transaction) []byte { return txSignBytes(tx) } + +// txSignBytes returns the canonical bytes that are signed for a transaction. +func txSignBytes(tx *blockchain.Transaction) []byte { + data, _ := json.Marshal(struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + From string `json:"from"` + To string `json:"to"` + Amount uint64 `json:"amount"` + Fee uint64 `json:"fee"` + Payload []byte `json:"payload"` + Timestamp time.Time `json:"timestamp"` + }{ + tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp, + }) + return data +} + +// VerifyTx verifies a transaction's Ed25519 signature. +func VerifyTx(tx *blockchain.Transaction) error { + ok, err := Verify(tx.From, txSignBytes(tx), tx.Signature) + if err != nil { + return err + } + if !ok { + return errors.New("transaction signature invalid") + } + return nil +} diff --git a/identity/identity_test.go b/identity/identity_test.go new file mode 100644 index 0000000..7cd09da --- /dev/null +++ b/identity/identity_test.go @@ -0,0 +1,257 @@ +package identity_test + +import ( + "strings" + "testing" + + "go-blockchain/identity" +) + +func mustGenerate(t *testing.T) *identity.Identity { + t.Helper() + id, err := identity.Generate() + if err != nil { + t.Fatalf("identity.Generate: %v", err) + } + return id +} + +// TestGenerate checks that a freshly generated identity has non-zero keys of +// the expected lengths. +func TestGenerate(t *testing.T) { + id := mustGenerate(t) + + // Ed25519 public key: 32 bytes → 64 hex chars. + pubHex := id.PubKeyHex() + if len(pubHex) != 64 { + t.Errorf("PubKeyHex length: got %d, want 64", len(pubHex)) + } + + // Ed25519 private key (seed + pub): 64 bytes → 128 hex chars. + privHex := id.PrivKeyHex() + if len(privHex) != 128 { + t.Errorf("PrivKeyHex length: got %d, want 128", len(privHex)) + } + + // X25519 keys: 32 bytes each → 64 hex chars each. + if len(id.X25519PubHex()) != 64 { + t.Errorf("X25519PubHex length: got %d, want 64", len(id.X25519PubHex())) + } + if len(id.X25519PrivHex()) != 64 { + t.Errorf("X25519PrivHex length: got %d, want 64", len(id.X25519PrivHex())) + } + + // Keys must not be all-zero strings. + allZero := strings.Repeat("0", 64) + if pubHex == allZero { + t.Error("PubKeyHex is all zeros") + } +} + +// TestGenerateUnique verifies that two Generate calls produce distinct keypairs. +func TestGenerateUnique(t *testing.T) { + id1 := mustGenerate(t) + id2 := mustGenerate(t) + + if id1.PubKeyHex() == id2.PubKeyHex() { + t.Error("two Generate calls produced the same Ed25519 public key") + } + if id1.X25519PubHex() == id2.X25519PubHex() { + t.Error("two Generate calls produced the same X25519 public key") + } +} + +// TestSignVerify signs a message and verifies that the signature is valid. +func TestSignVerify(t *testing.T) { + id := mustGenerate(t) + msg := []byte("hello blockchain") + sig := id.Sign(msg) + + ok, err := identity.Verify(id.PubKeyHex(), msg, sig) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !ok { + t.Error("Verify should return true for a valid signature") + } +} + +// TestVerifyWrongKey verifies that a signature fails when checked against a +// different public key (should return false, not an error). +func TestVerifyWrongKey(t *testing.T) { + id1 := mustGenerate(t) + id2 := mustGenerate(t) + + msg := []byte("hello blockchain") + sig := id1.Sign(msg) + + ok, err := identity.Verify(id2.PubKeyHex(), msg, sig) + if err != nil { + t.Fatalf("Verify returned unexpected error: %v", err) + } + if ok { + t.Error("Verify should return false when checked against a different public key") + } +} + +// TestVerifyTamperedMessage verifies that a signature is invalid when the +// message is modified after signing. +func TestVerifyTamperedMessage(t *testing.T) { + id := mustGenerate(t) + msg := []byte("original message") + sig := id.Sign(msg) + + tampered := []byte("tampered message") + ok, err := identity.Verify(id.PubKeyHex(), tampered, sig) + if err != nil { + t.Fatalf("Verify returned unexpected error: %v", err) + } + if ok { + t.Error("Verify should return false for a tampered message") + } +} + +// TestFromHexRoundTrip serialises an identity to hex, reconstructs it via +// FromHex, and verifies that the reconstructed identity can sign and verify. +func TestFromHexRoundTrip(t *testing.T) { + orig := mustGenerate(t) + + restored, err := identity.FromHex(orig.PubKeyHex(), orig.PrivKeyHex()) + if err != nil { + t.Fatalf("FromHex: %v", err) + } + + if restored.PubKeyHex() != orig.PubKeyHex() { + t.Errorf("PubKeyHex mismatch after FromHex round-trip") + } + + msg := []byte("round-trip test") + sig := restored.Sign(msg) + + ok, err := identity.Verify(orig.PubKeyHex(), msg, sig) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !ok { + t.Error("signature from restored identity should verify against original public key") + } +} + +// TestFromHexFullRoundTrip serialises all four keys and reconstructs via +// FromHexFull, checking both Ed25519 and X25519 key equality. +func TestFromHexFullRoundTrip(t *testing.T) { + orig := mustGenerate(t) + + restored, err := identity.FromHexFull( + orig.PubKeyHex(), orig.PrivKeyHex(), + orig.X25519PubHex(), orig.X25519PrivHex(), + ) + if err != nil { + t.Fatalf("FromHexFull: %v", err) + } + + if restored.PubKeyHex() != orig.PubKeyHex() { + t.Error("Ed25519 public key mismatch after FromHexFull round-trip") + } + if restored.X25519PubHex() != orig.X25519PubHex() { + t.Error("X25519 public key mismatch after FromHexFull round-trip") + } + if restored.X25519PrivHex() != orig.X25519PrivHex() { + t.Error("X25519 private key mismatch after FromHexFull round-trip") + } + + // Ed25519 sign+verify still works after full round-trip. + msg := []byte("full round-trip test") + sig := restored.Sign(msg) + ok, err := identity.Verify(orig.PubKeyHex(), msg, sig) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !ok { + t.Error("signature from fully restored identity should verify") + } +} + +// TestFromHexMissingX25519 verifies that FromHexFull with empty X25519 strings +// derives a valid (non-zero) X25519 keypair deterministically from the Ed25519 key. +func TestFromHexMissingX25519(t *testing.T) { + orig := mustGenerate(t) + + id, err := identity.FromHexFull(orig.PubKeyHex(), orig.PrivKeyHex(), "", "") + if err != nil { + t.Fatalf("FromHexFull: %v", err) + } + + // Derived X25519 keys must be non-zero. + allZero := strings.Repeat("0", 64) + if id.X25519PubHex() == allZero { + t.Error("X25519PubHex should be derived (non-zero) when empty string passed") + } + if id.X25519PrivHex() == allZero { + t.Error("X25519PrivHex should be derived (non-zero) when empty string passed") + } + + // Calling again with the same Ed25519 key must produce the same X25519 keys (deterministic). + id2, err := identity.FromHexFull(orig.PubKeyHex(), orig.PrivKeyHex(), "", "") + if err != nil { + t.Fatalf("FromHexFull second call: %v", err) + } + if id.X25519PubHex() != id2.X25519PubHex() { + t.Error("X25519 derivation is not deterministic") + } +} + +// TestMineRegistration runs proof-of-work at difficulty 16 and verifies that +// the resulting target hash starts with "0000". +// MineRegistration uses difficulty/4 as the number of leading hex zeros, so +// difficulty=16 produces a 4-zero prefix. This completes in well under a second. +func TestMineRegistration(t *testing.T) { + id := mustGenerate(t) + + // difficulty/4 == 4 leading hex zeros → prefix "0000". + nonce, target, err := identity.MineRegistration(id.PubKeyHex(), 16) + if err != nil { + t.Fatalf("MineRegistration: %v", err) + } + + // The nonce is just a counter — any value is acceptable. + _ = nonce + + if !strings.HasPrefix(target, "0000") { + t.Errorf("target should start with '0000' for difficulty 16, got %s", target) + } +} + +// TestRegisterTxValid builds a REGISTER_KEY transaction at difficulty 4 and +// verifies the signature via VerifyTx. +func TestRegisterTxValid(t *testing.T) { + id := mustGenerate(t) + + tx, err := identity.RegisterTx(id, "testnode", 4) + if err != nil { + t.Fatalf("RegisterTx: %v", err) + } + if tx == nil { + t.Fatal("RegisterTx returned nil transaction") + } + + if err := identity.VerifyTx(tx); err != nil { + t.Errorf("VerifyTx should return nil for a freshly built transaction, got: %v", err) + } +} + +// TestX25519KeyLengths checks that X25519PubHex and X25519PrivHex are each +// exactly 64 hex characters (32 bytes). +func TestX25519KeyLengths(t *testing.T) { + id := mustGenerate(t) + + pubHex := id.X25519PubHex() + privHex := id.X25519PrivHex() + + if len(pubHex) != 64 { + t.Errorf("X25519PubHex: expected 64 hex chars (32 bytes), got %d", len(pubHex)) + } + if len(privHex) != 64 { + t.Errorf("X25519PrivHex: expected 64 hex chars (32 bytes), got %d", len(privHex)) + } +} diff --git a/node/api_chain_v2.go b/node/api_chain_v2.go new file mode 100644 index 0000000..6b2b02d --- /dev/null +++ b/node/api_chain_v2.go @@ -0,0 +1,367 @@ +package node + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "go-blockchain/blockchain" + "go-blockchain/wallet" +) + +// V2ChainTx is a chain-native transaction representation for /v2/chain endpoints. +type V2ChainTx struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + Memo string `json:"memo,omitempty"` + From string `json:"from,omitempty"` + FromAddr string `json:"from_addr,omitempty"` + To string `json:"to,omitempty"` + ToAddr string `json:"to_addr,omitempty"` + AmountUT uint64 `json:"amount_ut"` + FeeUT uint64 `json:"fee_ut"` + BlockIndex uint64 `json:"block_index"` + BlockHash string `json:"block_hash,omitempty"` + Time string `json:"time"` +} + +func asV2ChainTx(rec *blockchain.TxRecord) V2ChainTx { + tx := rec.Tx + out := V2ChainTx{ + ID: tx.ID, + Type: tx.Type, + Memo: txMemo(tx), + From: tx.From, + To: tx.To, + AmountUT: tx.Amount, + FeeUT: tx.Fee, + BlockIndex: rec.BlockIndex, + BlockHash: rec.BlockHash, + Time: rec.BlockTime.UTC().Format("2006-01-02T15:04:05Z"), + } + if tx.From != "" { + out.FromAddr = wallet.PubKeyToAddress(tx.From) + } + if tx.To != "" { + out.ToAddr = wallet.PubKeyToAddress(tx.To) + } + return out +} + +func apiV2ChainAccountTransactions(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + path := strings.TrimPrefix(r.URL.Path, "/v2/chain/accounts/") + parts := strings.Split(path, "/") + if len(parts) != 2 || parts[1] != "transactions" { + http.NotFound(w, r) + return + } + + pubKey, err := resolveAccountID(q, parts[0]) + if err != nil { + jsonErr(w, err, 404) + return + } + + afterBlock, err := queryUint64Optional(r, "after_block") + if err != nil { + jsonErr(w, err, 400) + return + } + beforeBlock, err := queryUint64Optional(r, "before_block") + if err != nil { + jsonErr(w, err, 400) + return + } + + limit := queryInt(r, "limit", 100) + if limit > 1000 { + limit = 1000 + } + order := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("order"))) + if order == "" { + order = "desc" + } + if order != "desc" && order != "asc" { + jsonErr(w, fmt.Errorf("invalid order: %s", order), 400) + return + } + + // Fetch enough records to fill the filtered result. + // If block filters are provided, over-fetch to avoid missing results. + fetchLimit := limit + if afterBlock != nil || beforeBlock != nil { + fetchLimit = 1000 + } + recs, err := q.TxsByAddress(pubKey, fetchLimit, 0) + if err != nil { + jsonErr(w, err, 500) + return + } + + filtered := make([]*blockchain.TxRecord, 0, len(recs)) + for _, rec := range recs { + if afterBlock != nil && rec.BlockIndex <= *afterBlock { + continue + } + if beforeBlock != nil && rec.BlockIndex >= *beforeBlock { + continue + } + filtered = append(filtered, rec) + } + + sort.Slice(filtered, func(i, j int) bool { + if filtered[i].BlockIndex == filtered[j].BlockIndex { + if order == "asc" { + return filtered[i].BlockTime.Before(filtered[j].BlockTime) + } + return filtered[i].BlockTime.After(filtered[j].BlockTime) + } + if order == "asc" { + return filtered[i].BlockIndex < filtered[j].BlockIndex + } + return filtered[i].BlockIndex > filtered[j].BlockIndex + }) + + if len(filtered) > limit { + filtered = filtered[:limit] + } + items := make([]V2ChainTx, len(filtered)) + for i := range filtered { + items[i] = asV2ChainTx(filtered[i]) + } + + jsonOK(w, map[string]any{ + "account_id": pubKey, + "account_addr": wallet.PubKeyToAddress(pubKey), + "count": len(items), + "transactions": items, + "order": order, + "limit_applied": limit, + }) + } +} + +func apiV2ChainTxByID(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + txID := strings.TrimPrefix(r.URL.Path, "/v2/chain/transactions/") + txID = strings.TrimSuffix(txID, "/") + if txID == "" || strings.Contains(txID, "/") { + jsonErr(w, fmt.Errorf("transaction id required"), 400) + return + } + rec, err := q.GetTx(txID) + if err != nil { + jsonErr(w, err, 500) + return + } + if rec == nil { + jsonErr(w, fmt.Errorf("transaction not found"), 404) + return + } + payload, payloadHex := decodeTxPayload(rec.Tx.Payload) + jsonOK(w, map[string]any{ + "tx": asV2ChainTx(rec), + "payload": payload, + "payload_hex": payloadHex, + "signature_hex": fmt.Sprintf("%x", rec.Tx.Signature), + }) + } +} + +type v2ChainSubmitReq struct { + Tx *blockchain.Transaction `json:"tx,omitempty"` + SignedTx string `json:"signed_tx,omitempty"` // json/base64/hex envelope +} + +type v2ChainDraftReq struct { + From string `json:"from"` + To string `json:"to"` + AmountUT uint64 `json:"amount_ut"` + Memo string `json:"memo,omitempty"` + FeeUT uint64 `json:"fee_ut,omitempty"` +} + +func buildTransferDraft(req v2ChainDraftReq) (*blockchain.Transaction, error) { + from := strings.TrimSpace(req.From) + to := strings.TrimSpace(req.To) + if from == "" || to == "" { + return nil, fmt.Errorf("from and to are required") + } + if req.AmountUT == 0 { + return nil, fmt.Errorf("amount_ut must be > 0") + } + fee := req.FeeUT + if fee == 0 { + fee = blockchain.MinFee + } + memo := strings.TrimSpace(req.Memo) + payload, err := json.Marshal(blockchain.TransferPayload{Memo: memo}) + if err != nil { + return nil, err + } + now := time.Now().UTC() + return &blockchain.Transaction{ + ID: fmt.Sprintf("tx-%d", now.UnixNano()), + Type: blockchain.EventTransfer, + From: from, + To: to, + Amount: req.AmountUT, + Fee: fee, + Memo: memo, + Payload: payload, + Timestamp: now, + }, nil +} + +func applyTransferDefaults(tx *blockchain.Transaction) error { + if tx == nil { + return fmt.Errorf("transaction is nil") + } + if tx.Type != blockchain.EventTransfer { + return nil + } + if tx.ID == "" { + tx.ID = fmt.Sprintf("tx-%d", time.Now().UTC().UnixNano()) + } + if tx.Timestamp.IsZero() { + tx.Timestamp = time.Now().UTC() + } + if tx.Fee == 0 { + tx.Fee = blockchain.MinFee + } + if len(tx.Payload) == 0 { + payload, err := json.Marshal(blockchain.TransferPayload{Memo: tx.Memo}) + if err != nil { + return err + } + tx.Payload = payload + } + return nil +} + +func apiV2ChainDraftTx() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + bodyBytes, err := ioReadAll(w, r) + if err != nil { + jsonErr(w, err, 400) + return + } + var req v2ChainDraftReq + if err := json.Unmarshal(bodyBytes, &req); err != nil { + jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400) + return + } + + tx, err := buildTransferDraft(req) + if err != nil { + jsonErr(w, err, 400) + return + } + + signTx := *tx + signTx.Signature = nil + signBytes, _ := json.Marshal(&signTx) + + jsonOK(w, map[string]any{ + "tx": tx, + "sign_bytes_hex": fmt.Sprintf("%x", signBytes), + "sign_bytes_base64": base64.StdEncoding.EncodeToString(signBytes), + "note": "Sign sign_bytes with sender private key and submit signed tx to POST /v2/chain/transactions.", + }) + } +} + +func apiV2ChainSendTx(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + + var req v2ChainSubmitReq + bodyBytes, err := ioReadAll(w, r) + if err != nil { + jsonErr(w, err, 400) + return + } + if err := json.Unmarshal(bodyBytes, &req); err != nil { + jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400) + return + } + + var tx *blockchain.Transaction + if strings.TrimSpace(req.SignedTx) != "" { + tx, err = decodeTransactionEnvelope(req.SignedTx) + if err != nil { + jsonErr(w, err, 400) + return + } + } + if tx == nil { + tx = req.Tx + } + if tx == nil { + // Also allow direct transaction JSON as request body. + var direct blockchain.Transaction + if err := json.Unmarshal(bodyBytes, &direct); err == nil && direct.ID != "" { + tx = &direct + } + } + if tx == nil { + jsonErr(w, fmt.Errorf("missing tx in request body"), 400) + return + } + if err := applyTransferDefaults(tx); err != nil { + jsonErr(w, err, 400) + return + } + if len(tx.Signature) == 0 { + jsonErr(w, fmt.Errorf("signature is required; use /v2/chain/transactions/draft to prepare tx"), 400) + return + } + if err := ValidateTxTimestamp(tx); err != nil { + jsonErr(w, fmt.Errorf("bad timestamp: %w", err), 400) + return + } + if err := verifyTransactionSignature(tx); err != nil { + jsonErr(w, err, 400) + return + } + if err := q.SubmitTx(tx); err != nil { + jsonErr(w, err, 500) + return + } + + jsonOK(w, map[string]any{ + "status": "accepted", + "id": tx.ID, + }) + } +} + +func ioReadAll(w http.ResponseWriter, r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, fmt.Errorf("empty request body") + } + defer r.Body.Close() + const max = 2 << 20 // 2 MiB + return io.ReadAll(http.MaxBytesReader(w, r.Body, max)) +} diff --git a/node/api_channels.go b/node/api_channels.go new file mode 100644 index 0000000..21cf604 --- /dev/null +++ b/node/api_channels.go @@ -0,0 +1,102 @@ +// Package node — channel endpoints. +// +// `/api/channels/:id/members` returns every Ed25519 pubkey registered as a +// channel member together with their current X25519 pubkey (from the +// identity registry). Clients sealing a message to a channel iterate this +// list and call relay.Seal once per recipient — that's the "fan-out" +// group-messaging model (R1 in the roadmap). +// +// Why enrich with X25519 here rather than making the client do it? +// - One HTTP round trip vs N. At 10+ members the latency difference is +// significant over mobile networks. +// - The server already holds the identity state; no extra DB hops. +// - Clients get a stable, already-joined view — if a member hasn't +// published an X25519 key yet, we return them with `x25519_pub_key=""` +// so the caller knows to skip or retry later. +package node + +import ( + "fmt" + "net/http" + "strings" + + "go-blockchain/blockchain" + "go-blockchain/wallet" +) + +func registerChannelAPI(mux *http.ServeMux, q ExplorerQuery) { + // GET /api/channels/{id} → channel metadata + // GET /api/channels/{id}/members → enriched member list + // + // One HandleFunc deals with both by sniffing the path suffix. + mux.HandleFunc("/api/channels/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + path := strings.TrimPrefix(r.URL.Path, "/api/channels/") + path = strings.Trim(path, "/") + if path == "" { + jsonErr(w, fmt.Errorf("channel id required"), 400) + return + } + switch { + case strings.HasSuffix(path, "/members"): + id := strings.TrimSuffix(path, "/members") + handleChannelMembers(w, q, id) + default: + handleChannelInfo(w, q, path) + } + }) +} + +func handleChannelInfo(w http.ResponseWriter, q ExplorerQuery, channelID string) { + if q.GetChannel == nil { + jsonErr(w, fmt.Errorf("channel queries not configured"), 503) + return + } + ch, err := q.GetChannel(channelID) + if err != nil { + jsonErr(w, err, 500) + return + } + if ch == nil { + jsonErr(w, fmt.Errorf("channel %s not found", channelID), 404) + return + } + jsonOK(w, ch) +} + +func handleChannelMembers(w http.ResponseWriter, q ExplorerQuery, channelID string) { + if q.GetChannelMembers == nil { + jsonErr(w, fmt.Errorf("channel queries not configured"), 503) + return + } + pubs, err := q.GetChannelMembers(channelID) + if err != nil { + jsonErr(w, err, 500) + return + } + out := make([]blockchain.ChannelMember, 0, len(pubs)) + for _, pub := range pubs { + member := blockchain.ChannelMember{ + PubKey: pub, + Address: wallet.PubKeyToAddress(pub), + } + // Best-effort X25519 lookup — skip silently on miss so a member + // who hasn't published their identity yet doesn't prevent the + // whole list from returning. The sender will just skip them on + // fan-out and retry later (after that member does register). + if q.IdentityInfo != nil { + if info, err := q.IdentityInfo(pub); err == nil && info != nil { + member.X25519PubKey = info.X25519Pub + } + } + out = append(out, member) + } + jsonOK(w, map[string]any{ + "channel_id": channelID, + "count": len(out), + "members": out, + }) +} diff --git a/node/api_common.go b/node/api_common.go new file mode 100644 index 0000000..37bd2a7 --- /dev/null +++ b/node/api_common.go @@ -0,0 +1,181 @@ +package node + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "go-blockchain/blockchain" + "go-blockchain/identity" +) + +func jsonOK(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + _ = json.NewEncoder(w).Encode(v) +} + +func jsonErr(w http.ResponseWriter, err error, code int) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) +} + +func queryInt(r *http.Request, key string, def int) int { + s := r.URL.Query().Get(key) + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return def + } + return n +} + +// queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid. +func queryIntMin0(r *http.Request, key string) int { + s := r.URL.Query().Get(key) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return 0 + } + return n +} + +func queryUint64Optional(r *http.Request, key string) (*uint64, error) { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return nil, nil + } + n, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid %s: %s", key, raw) + } + return &n, nil +} + +func resolveAccountID(q ExplorerQuery, accountID string) (string, error) { + if accountID == "" { + return "", fmt.Errorf("account id required") + } + if strings.HasPrefix(accountID, "DC") { + pubKey, err := q.AddressToPubKey(accountID) + if err != nil { + return "", err + } + if pubKey == "" { + return "", fmt.Errorf("account not found") + } + return pubKey, nil + } + return accountID, nil +} + +func verifyTransactionSignature(tx *blockchain.Transaction) error { + if tx == nil { + return fmt.Errorf("transaction is nil") + } + return identity.VerifyTx(tx) +} + +func decodeTransactionEnvelope(raw string) (*blockchain.Transaction, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty transaction envelope") + } + + tryDecodeJSON := func(data []byte) (*blockchain.Transaction, error) { + var tx blockchain.Transaction + if err := json.Unmarshal(data, &tx); err != nil { + return nil, err + } + if tx.ID == "" || tx.From == "" || tx.Type == "" { + return nil, fmt.Errorf("invalid tx payload") + } + return &tx, nil + } + + if strings.HasPrefix(raw, "{") { + return tryDecodeJSON([]byte(raw)) + } + + base64Decoders := []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } + for _, enc := range base64Decoders { + if b, err := enc.DecodeString(raw); err == nil { + if tx, txErr := tryDecodeJSON(b); txErr == nil { + return tx, nil + } + } + } + + if b, err := hex.DecodeString(raw); err == nil { + if tx, txErr := tryDecodeJSON(b); txErr == nil { + return tx, nil + } + } + + return nil, fmt.Errorf("failed to decode transaction envelope") +} + +func txMemo(tx *blockchain.Transaction) string { + if tx == nil { + return "" + } + if memo := strings.TrimSpace(tx.Memo); memo != "" { + return memo + } + switch tx.Type { + case blockchain.EventTransfer: + var p blockchain.TransferPayload + if err := json.Unmarshal(tx.Payload, &p); err == nil { + return strings.TrimSpace(p.Memo) + } + case blockchain.EventBlockReward: + return blockRewardReason(tx.Payload) + case blockchain.EventRelayProof: + return "Relay delivery fee" + case blockchain.EventHeartbeat: + return "Liveness heartbeat" + case blockchain.EventRegisterRelay: + return "Register relay service" + case blockchain.EventBindWallet: + return "Bind payout wallet" + } + return "" +} + +func blockRewardReason(payload []byte) string { + var p blockchain.BlockRewardPayload + if err := json.Unmarshal(payload, &p); err != nil { + return "Block fees" + } + if p.FeeReward == 0 && p.TotalReward > 0 { + return "Genesis allocation" + } + return "Block fees collected" +} + +func decodeTxPayload(payload []byte) (any, string) { + if len(payload) == 0 { + return nil, "" + } + var decoded any + if err := json.Unmarshal(payload, &decoded); err == nil { + return decoded, "" + } + return nil, hex.EncodeToString(payload) +} diff --git a/node/api_contract.go b/node/api_contract.go new file mode 100644 index 0000000..086407b --- /dev/null +++ b/node/api_contract.go @@ -0,0 +1,199 @@ +package node + +import ( + "encoding/base64" + "encoding/binary" + "encoding/hex" + "fmt" + "net/http" + "strconv" + "strings" + + "go-blockchain/blockchain" +) + +// registerContractAPI mounts the contract API routes on mux. +// +// GET /api/contracts — list all deployed contracts +// GET /api/contracts/{contractID} — contract metadata (no WASM bytes) +// GET /api/contracts/{contractID}/state/{key} — raw state value, base64-encoded +// GET /api/contracts/{contractID}/logs — recent log entries (newest first) +func registerContractAPI(mux *http.ServeMux, q ExplorerQuery) { + // Exact match for list endpoint (no trailing slash) + mux.HandleFunc("/api/contracts", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, errorf("method not allowed"), 405) + return + } + if q.GetContracts == nil { + jsonErr(w, errorf("contract queries not available"), 503) + return + } + contracts, err := q.GetContracts() + if err != nil { + jsonErr(w, err, 500) + return + } + if contracts == nil { + contracts = []blockchain.ContractRecord{} + } + jsonOK(w, map[string]any{ + "count": len(contracts), + "contracts": contracts, + }) + }) + + mux.HandleFunc("/api/contracts/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, errorf("method not allowed"), 405) + return + } + // Path segments after /api/contracts/: + // "<id>" → contract info + // "<id>/state/<k>" → state value + // "<id>/logs" → log entries + path := strings.TrimPrefix(r.URL.Path, "/api/contracts/") + path = strings.Trim(path, "/") + + switch { + case strings.Contains(path, "/state/"): + parts := strings.SplitN(path, "/state/", 2) + contractID := parts[0] + if contractID == "" { + jsonErr(w, errorf("contract_id required"), 400) + return + } + handleContractState(w, q, contractID, parts[1]) + + case strings.HasSuffix(path, "/logs"): + contractID := strings.TrimSuffix(path, "/logs") + if contractID == "" { + jsonErr(w, errorf("contract_id required"), 400) + return + } + handleContractLogs(w, r, q, contractID) + + default: + contractID := path + if contractID == "" { + jsonErr(w, errorf("contract_id required"), 400) + return + } + handleContractInfo(w, q, contractID) + } + }) +} + +func handleContractInfo(w http.ResponseWriter, q ExplorerQuery, contractID string) { + // Check native contracts first — they aren't stored as WASM ContractRecord + // in BadgerDB but are valid targets for CALL_CONTRACT and have an ABI. + if q.NativeContracts != nil { + for _, nc := range q.NativeContracts() { + if nc.ContractID == contractID { + jsonOK(w, map[string]any{ + "contract_id": nc.ContractID, + "deployer_pub": "", + "deployed_at": uint64(0), // native contracts exist from genesis + "abi_json": nc.ABIJson, + "wasm_size": 0, + "native": true, + }) + return + } + } + } + if q.GetContract == nil { + jsonErr(w, errorf("contract queries not available"), 503) + return + } + rec, err := q.GetContract(contractID) + if err != nil { + jsonErr(w, err, 500) + return + } + if rec == nil { + jsonErr(w, errorf("contract %s not found", contractID), 404) + return + } + // Omit raw WASM bytes from the API response; expose only metadata. + jsonOK(w, map[string]any{ + "contract_id": rec.ContractID, + "deployer_pub": rec.DeployerPub, + "deployed_at": rec.DeployedAt, + "abi_json": rec.ABIJson, + "wasm_size": len(rec.WASMBytes), + "native": false, + }) +} + +func handleContractState(w http.ResponseWriter, q ExplorerQuery, contractID, key string) { + if q.GetContractState == nil { + jsonErr(w, errorf("contract state queries not available"), 503) + return + } + val, err := q.GetContractState(contractID, key) + if err != nil { + jsonErr(w, err, 500) + return + } + if val == nil { + jsonOK(w, map[string]any{ + "contract_id": contractID, + "key": key, + "value_b64": nil, + "value_hex": nil, + }) + return + } + jsonOK(w, map[string]any{ + "contract_id": contractID, + "key": key, + "value_b64": base64.StdEncoding.EncodeToString(val), + "value_hex": hexEncode(val), + "value_u64": decodeU64(val), // convenience: big-endian uint64 if len==8 + }) +} + +func handleContractLogs(w http.ResponseWriter, r *http.Request, q ExplorerQuery, contractID string) { + if q.GetContractLogs == nil { + jsonErr(w, errorf("contract log queries not available"), 503) + return + } + limit := 50 + if s := r.URL.Query().Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 { + limit = n + } + } + entries, err := q.GetContractLogs(contractID, limit) + if err != nil { + jsonErr(w, err, 500) + return + } + if entries == nil { + entries = []blockchain.ContractLogEntry{} + } + jsonOK(w, map[string]any{ + "contract_id": contractID, + "count": len(entries), + "logs": entries, + }) +} + +// errorf is a helper to create a formatted error. +func errorf(format string, args ...any) error { + if len(args) == 0 { + return fmt.Errorf("%s", format) + } + return fmt.Errorf(format, args...) +} + +func hexEncode(b []byte) string { return hex.EncodeToString(b) } + +func decodeU64(b []byte) *uint64 { + if len(b) != 8 { + return nil + } + v := binary.BigEndian.Uint64(b) + return &v +} diff --git a/node/api_explorer.go b/node/api_explorer.go new file mode 100644 index 0000000..d016c93 --- /dev/null +++ b/node/api_explorer.go @@ -0,0 +1,553 @@ +package node + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "go-blockchain/blockchain" + "go-blockchain/economy" + "go-blockchain/wallet" +) + +type txListEntry struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + Memo string `json:"memo,omitempty"` + From string `json:"from"` + FromAddr string `json:"from_addr,omitempty"` + To string `json:"to,omitempty"` + ToAddr string `json:"to_addr,omitempty"` + Amount uint64 `json:"amount_ut"` + AmountDisp string `json:"amount"` + Fee uint64 `json:"fee_ut"` + FeeDisp string `json:"fee"` + Time string `json:"time"` + BlockIndex uint64 `json:"block_index"` + BlockHash string `json:"block_hash,omitempty"` +} + +func asTxListEntry(rec *blockchain.TxRecord) txListEntry { + tx := rec.Tx + out := txListEntry{ + ID: tx.ID, + Type: tx.Type, + Memo: txMemo(tx), + From: tx.From, + To: tx.To, + Amount: tx.Amount, + AmountDisp: economy.FormatTokens(tx.Amount), + Fee: tx.Fee, + FeeDisp: economy.FormatTokens(tx.Fee), + Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"), + BlockIndex: rec.BlockIndex, + BlockHash: rec.BlockHash, + } + if tx.From != "" { + out.FromAddr = wallet.PubKeyToAddress(tx.From) + } + if tx.To != "" { + out.ToAddr = wallet.PubKeyToAddress(tx.To) + } + return out +} + +func apiNetStats(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + stats, err := q.NetStats() + if err != nil { + jsonErr(w, err, 500) + return + } + jsonOK(w, stats) + } +} + +func apiRecentBlocks(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + limit := queryInt(r, "limit", 20) + blocks, err := q.RecentBlocks(limit) + if err != nil { + jsonErr(w, err, 500) + return + } + type blockSummary struct { + Index uint64 `json:"index"` + Hash string `json:"hash"` + Time string `json:"time"` + Validator string `json:"validator"` + TxCount int `json:"tx_count"` + TotalFees uint64 `json:"total_fees_ut"` + } + out := make([]blockSummary, len(blocks)) + for i, b := range blocks { + out[i] = blockSummary{ + Index: b.Index, + Hash: b.HashHex(), + Time: b.Timestamp.UTC().Format("2006-01-02T15:04:05Z"), + Validator: wallet.PubKeyToAddress(b.Validator), + TxCount: len(b.Transactions), + TotalFees: b.TotalFees, + } + } + jsonOK(w, out) + } +} + +func apiRecentTxs(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + limit := queryInt(r, "limit", 20) + recs, err := q.RecentTxs(limit) + if err != nil { + jsonErr(w, err, 500) + return + } + out := make([]txListEntry, len(recs)) + for i := range recs { + out[i] = asTxListEntry(recs[i]) + } + jsonOK(w, out) + } +} + +func apiBlock(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + idxStr := strings.TrimPrefix(r.URL.Path, "/api/block/") + idx, err := strconv.ParseUint(idxStr, 10, 64) + if err != nil { + jsonErr(w, fmt.Errorf("invalid block index: %s", idxStr), 400) + return + } + b, err := q.GetBlock(idx) + if err != nil { + jsonErr(w, err, 404) + return + } + type txSummary struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + Memo string `json:"memo,omitempty"` + From string `json:"from"` + To string `json:"to,omitempty"` + Amount uint64 `json:"amount_ut,omitempty"` + Fee uint64 `json:"fee_ut"` + } + type blockDetail struct { + Index uint64 `json:"index"` + Hash string `json:"hash"` + PrevHash string `json:"prev_hash"` + Time string `json:"time"` + Validator string `json:"validator"` + ValidatorAddr string `json:"validator_addr"` + TxCount int `json:"tx_count"` + TotalFees uint64 `json:"total_fees_ut"` + Transactions []txSummary `json:"transactions"` + } + txs := make([]txSummary, len(b.Transactions)) + for i, tx := range b.Transactions { + txs[i] = txSummary{ + ID: tx.ID, + Type: tx.Type, + Memo: txMemo(tx), + From: tx.From, + To: tx.To, + Amount: tx.Amount, + Fee: tx.Fee, + } + } + jsonOK(w, blockDetail{ + Index: b.Index, + Hash: b.HashHex(), + PrevHash: fmt.Sprintf("%x", b.PrevHash), + Time: b.Timestamp.UTC().Format("2006-01-02T15:04:05Z"), + Validator: b.Validator, + ValidatorAddr: wallet.PubKeyToAddress(b.Validator), + TxCount: len(b.Transactions), + TotalFees: b.TotalFees, + Transactions: txs, + }) + } +} + +func apiTxByID(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txID := strings.TrimPrefix(r.URL.Path, "/api/tx/") + if txID == "" { + jsonErr(w, fmt.Errorf("tx id required"), 400) + return + } + rec, err := q.GetTx(txID) + if err != nil { + jsonErr(w, err, 500) + return + } + if rec == nil { + jsonErr(w, fmt.Errorf("transaction not found"), 404) + return + } + type txDetail struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + Memo string `json:"memo,omitempty"` + From string `json:"from"` + FromAddr string `json:"from_addr,omitempty"` + To string `json:"to,omitempty"` + ToAddr string `json:"to_addr,omitempty"` + Amount uint64 `json:"amount_ut"` + AmountDisp string `json:"amount"` + Fee uint64 `json:"fee_ut"` + FeeDisp string `json:"fee"` + Time string `json:"time"` + BlockIndex uint64 `json:"block_index"` + BlockHash string `json:"block_hash"` + BlockTime string `json:"block_time"` + GasUsed uint64 `json:"gas_used,omitempty"` + Payload any `json:"payload,omitempty"` + PayloadHex string `json:"payload_hex,omitempty"` + SignatureHex string `json:"signature_hex,omitempty"` + } + tx := rec.Tx + payload, payloadHex := decodeTxPayload(tx.Payload) + out := txDetail{ + ID: tx.ID, + Type: tx.Type, + Memo: txMemo(tx), + From: tx.From, + To: tx.To, + Amount: tx.Amount, + AmountDisp: economy.FormatTokens(tx.Amount), + Fee: tx.Fee, + FeeDisp: economy.FormatTokens(tx.Fee), + Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"), + BlockIndex: rec.BlockIndex, + BlockHash: rec.BlockHash, + BlockTime: rec.BlockTime.UTC().Format("2006-01-02T15:04:05Z"), + GasUsed: rec.GasUsed, + Payload: payload, + PayloadHex: payloadHex, + SignatureHex: hex.EncodeToString(tx.Signature), + } + if tx.From != "" { + out.FromAddr = wallet.PubKeyToAddress(tx.From) + } + if tx.To != "" { + out.ToAddr = wallet.PubKeyToAddress(tx.To) + } + jsonOK(w, out) + } +} + +func apiAddress(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + addr := strings.TrimPrefix(r.URL.Path, "/api/address/") + if addr == "" { + jsonErr(w, fmt.Errorf("address required"), 400) + return + } + + pubKey, err := resolveAccountID(q, addr) + if err != nil { + jsonErr(w, err, 404) + return + } + + limit := queryInt(r, "limit", 50) + offset := queryIntMin0(r, "offset") + bal, err := q.Balance(pubKey) + if err != nil { + jsonErr(w, err, 500) + return + } + txs, err := q.TxsByAddress(pubKey, limit, offset) + if err != nil { + jsonErr(w, err, 500) + return + } + + type txEntry struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + Memo string `json:"memo,omitempty"` + From string `json:"from"` + FromAddr string `json:"from_addr,omitempty"` + To string `json:"to,omitempty"` + ToAddr string `json:"to_addr,omitempty"` + Amount uint64 `json:"amount_ut"` + AmountDisp string `json:"amount"` + Fee uint64 `json:"fee_ut"` + Time string `json:"time"` + BlockIndex uint64 `json:"block_index"` + } + entries := make([]txEntry, len(txs)) + for i, rec := range txs { + tx := rec.Tx + entries[i] = txEntry{ + ID: tx.ID, + Type: tx.Type, + Memo: txMemo(tx), + From: tx.From, + To: tx.To, + Amount: tx.Amount, + AmountDisp: economy.FormatTokens(tx.Amount), + Fee: tx.Fee, + Time: tx.Timestamp.UTC().Format("2006-01-02T15:04:05Z"), + BlockIndex: rec.BlockIndex, + } + if tx.From != "" { + entries[i].FromAddr = wallet.PubKeyToAddress(tx.From) + } + if tx.To != "" { + entries[i].ToAddr = wallet.PubKeyToAddress(tx.To) + } + } + type addrResp struct { + Address string `json:"address"` + PubKey string `json:"pub_key"` + BalanceMicroT uint64 `json:"balance_ut"` + Balance string `json:"balance"` + TxCount int `json:"tx_count"` + Offset int `json:"offset"` + Limit int `json:"limit"` + HasMore bool `json:"has_more"` + NextOffset int `json:"next_offset"` + Transactions []txEntry `json:"transactions"` + } + hasMore := len(entries) == limit + jsonOK(w, addrResp{ + Address: wallet.PubKeyToAddress(pubKey), + PubKey: pubKey, + BalanceMicroT: bal, + Balance: economy.FormatTokens(bal), + TxCount: len(entries), + Offset: offset, + Limit: limit, + HasMore: hasMore, + NextOffset: offset + len(entries), + Transactions: entries, + }) + } +} + +func apiNode(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + input := strings.TrimPrefix(r.URL.Path, "/api/node/") + if input == "" { + jsonErr(w, fmt.Errorf("node id required"), 400) + return + } + + pubKey, err := resolveAccountID(q, input) + if err != nil { + jsonErr(w, err, 404) + return + } + + rep, err := q.Reputation(pubKey) + if err != nil { + jsonErr(w, err, 500) + return + } + nodeBal, err := q.Balance(pubKey) + if err != nil { + jsonErr(w, err, 500) + return + } + walletPubKey, err := q.WalletBinding(pubKey) + if err != nil { + jsonErr(w, err, 500) + return + } + + var walletAddr string + var walletBalance uint64 + if walletPubKey != "" { + walletAddr = wallet.PubKeyToAddress(walletPubKey) + walletBalance, _ = q.Balance(walletPubKey) + } + + window := queryInt(r, "window", 200) + recentBlocks, err := q.RecentBlocks(window) + if err != nil { + jsonErr(w, err, 500) + return + } + + recentProduced := 0 + var recentRewardsUT uint64 + for _, b := range recentBlocks { + if b.Validator != pubKey { + continue + } + recentProduced++ + recentRewardsUT += b.TotalFees + } + + type nodeResp struct { + QueryInput string `json:"query_input"` + PubKey string `json:"pub_key"` + Address string `json:"address"` + NodeBalanceUT uint64 `json:"node_balance_ut"` + NodeBalance string `json:"node_balance"` + WalletBindingPubKey string `json:"wallet_binding_pub_key,omitempty"` + WalletBindingAddress string `json:"wallet_binding_address,omitempty"` + WalletBindingBalanceUT uint64 `json:"wallet_binding_balance_ut,omitempty"` + WalletBindingBalance string `json:"wallet_binding_balance,omitempty"` + ReputationScore int64 `json:"reputation_score"` + ReputationRank string `json:"reputation_rank"` + BlocksProduced uint64 `json:"blocks_produced"` + RelayProofs uint64 `json:"relay_proofs"` + SlashCount uint64 `json:"slash_count"` + Heartbeats uint64 `json:"heartbeats"` + LifetimeBaseRewardUT uint64 `json:"lifetime_base_reward_ut"` + LifetimeBaseReward string `json:"lifetime_base_reward"` + RecentWindowBlocks int `json:"recent_window_blocks"` + RecentBlocksProduced int `json:"recent_blocks_produced"` + RecentRewardsUT uint64 `json:"recent_rewards_ut"` + RecentRewards string `json:"recent_rewards"` + } + + jsonOK(w, nodeResp{ + QueryInput: input, + PubKey: pubKey, + Address: wallet.PubKeyToAddress(pubKey), + NodeBalanceUT: nodeBal, + NodeBalance: economy.FormatTokens(nodeBal), + WalletBindingPubKey: walletPubKey, + WalletBindingAddress: walletAddr, + WalletBindingBalanceUT: walletBalance, + WalletBindingBalance: economy.FormatTokens(walletBalance), + ReputationScore: rep.Score, + ReputationRank: rep.Rank(), + BlocksProduced: rep.BlocksProduced, + RelayProofs: rep.RelayProofs, + SlashCount: rep.SlashCount, + Heartbeats: rep.Heartbeats, + LifetimeBaseRewardUT: 0, + LifetimeBaseReward: economy.FormatTokens(0), + RecentWindowBlocks: len(recentBlocks), + RecentBlocksProduced: recentProduced, + RecentRewardsUT: recentRewardsUT, + RecentRewards: economy.FormatTokens(recentRewardsUT), + }) + } +} + +func apiRelays(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if q.RegisteredRelays == nil { + jsonOK(w, []any{}) + return + } + relays, err := q.RegisteredRelays() + if err != nil { + jsonErr(w, err, 500) + return + } + if relays == nil { + relays = []blockchain.RegisteredRelayInfo{} + } + jsonOK(w, relays) + } +} + +func apiValidators(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if q.ValidatorSet == nil { + jsonOK(w, []any{}) + return + } + validators, err := q.ValidatorSet() + if err != nil { + jsonErr(w, err, 500) + return + } + type validatorEntry struct { + PubKey string `json:"pub_key"` + Address string `json:"address"` + Staked uint64 `json:"staked_ut,omitempty"` + } + out := make([]validatorEntry, len(validators)) + for i, pk := range validators { + var staked uint64 + if q.Stake != nil { + staked, _ = q.Stake(pk) + } + out[i] = validatorEntry{ + PubKey: pk, + Address: wallet.PubKeyToAddress(pk), + Staked: staked, + } + } + jsonOK(w, map[string]any{ + "count": len(out), + "validators": out, + }) + } +} + +func apiIdentity(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + input := strings.TrimPrefix(r.URL.Path, "/api/identity/") + if input == "" { + jsonErr(w, fmt.Errorf("pubkey or address required"), 400) + return + } + if q.IdentityInfo == nil { + jsonErr(w, fmt.Errorf("identity lookup not available"), 503) + return + } + // Resolve DC address → pubkey if needed. + pubKey, err := resolveAccountID(q, input) + if err != nil { + jsonErr(w, err, 404) + return + } + info, err := q.IdentityInfo(pubKey) + if err != nil { + jsonErr(w, err, 500) + return + } + jsonOK(w, info) + } +} + +func apiSubmitTx(q ExplorerQuery) http.HandlerFunc { + // The returned handler is wrapped in withSubmitTxGuards() by the caller: + // body size is capped at MaxTxRequestBytes and per-IP rate limiting is + // applied upstream (see api_guards.go). This function therefore only + // handles the semantics — shape, signature, timestamp window, dispatch. + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + var tx blockchain.Transaction + if err := json.NewDecoder(r.Body).Decode(&tx); err != nil { + jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400) + return + } + // Reject txs with an obviously-bad clock value before we even verify + // the signature — cheaper failure, and cuts a replay window against + // long-rotated dedup caches. + if err := ValidateTxTimestamp(&tx); err != nil { + MetricTxSubmitRejected.Inc() + jsonErr(w, fmt.Errorf("bad timestamp: %w", err), 400) + return + } + if err := verifyTransactionSignature(&tx); err != nil { + MetricTxSubmitRejected.Inc() + jsonErr(w, err, 400) + return + } + if err := q.SubmitTx(&tx); err != nil { + MetricTxSubmitRejected.Inc() + jsonErr(w, err, 500) + return + } + MetricTxSubmitAccepted.Inc() + jsonOK(w, map[string]string{"id": tx.ID, "status": "accepted"}) + } +} diff --git a/node/api_guards.go b/node/api_guards.go new file mode 100644 index 0000000..265277d --- /dev/null +++ b/node/api_guards.go @@ -0,0 +1,299 @@ +// Package node — HTTP-level guards: body-size limits, timestamp windows, and +// a tiny per-IP token-bucket rate limiter. +// +// These are intentionally lightweight, dependency-free, and fail open if +// misconfigured. They do not replace proper production fronting (reverse +// proxy with rate-limit module), but they close the most obvious abuse +// vectors when the node is exposed directly. +package node + +import ( + "fmt" + "net" + "net/http" + "strings" + "sync" + "time" + + "go-blockchain/blockchain" +) + +// ─── Limits ────────────────────────────────────────────────────────────────── + +// MaxTxRequestBytes caps the size of a single POST /api/tx body. A signed +// transaction with a modest payload sits well under 16 KiB; we allow 64 KiB +// to accommodate small WASM deploys submitted by trusted signers. +// +// Note: DEPLOY_CONTRACT with a large WASM binary uses the same endpoint and +// therefore the same cap. If you deploy bigger contracts, raise this cap on +// the nodes that accept deploys, or (better) add a dedicated upload route. +const MaxTxRequestBytes int64 = 64 * 1024 + +// TxTimestampSkew is the maximum accepted deviation between a transaction's +// declared timestamp and the node's current wall clock. Transactions outside +// the window are rejected at the API layer to harden against replay + clock +// skew attacks (we do not retain old txIDs forever, so a very old but +// otherwise valid tx could otherwise slip in after its dedup entry rotates). +const TxTimestampSkew = 1 * time.Hour + +// ValidateTxTimestamp returns an error if tx.Timestamp is further than +// TxTimestampSkew from now. The zero-time is also rejected. +// +// Exported so the WS gateway (and other in-process callers) can reuse the +// same validation as the HTTP /api/tx path without importing the file-local +// helper. +func ValidateTxTimestamp(tx *blockchain.Transaction) error { + if tx.Timestamp.IsZero() { + return fmt.Errorf("timestamp is required") + } + now := time.Now().UTC() + delta := tx.Timestamp.Sub(now) + if delta < 0 { + delta = -delta + } + if delta > TxTimestampSkew { + return fmt.Errorf("timestamp %s is outside ±%s window of current time %s", + tx.Timestamp.Format(time.RFC3339), TxTimestampSkew, now.Format(time.RFC3339)) + } + return nil +} + +// ─── Rate limiter ──────────────────────────────────────────────────────────── + +// ipRateLimiter is a best-effort token bucket keyed by source IP. Buckets are +// created lazily and garbage-collected by a background sweep. +type ipRateLimiter struct { + rate float64 // tokens added per second + burst float64 // bucket capacity + sweepEvery time.Duration // how often to GC inactive buckets + inactiveTTL time.Duration // drop buckets idle longer than this + + mu sync.Mutex + buckets map[string]*bucket + stopCh chan struct{} +} + +type bucket struct { + tokens float64 + lastSeen time.Time +} + +// newIPRateLimiter constructs a limiter: each IP gets `burst` tokens that refill +// at `rate` per second. +func newIPRateLimiter(rate, burst float64) *ipRateLimiter { + l := &ipRateLimiter{ + rate: rate, + burst: burst, + sweepEvery: 2 * time.Minute, + inactiveTTL: 10 * time.Minute, + buckets: make(map[string]*bucket), + stopCh: make(chan struct{}), + } + go l.sweepLoop() + return l +} + +// Allow deducts one token for the given IP; returns false if the bucket is empty. +func (l *ipRateLimiter) Allow(ip string) bool { + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + + b, ok := l.buckets[ip] + if !ok { + b = &bucket{tokens: l.burst, lastSeen: now} + l.buckets[ip] = b + } + elapsed := now.Sub(b.lastSeen).Seconds() + b.tokens += elapsed * l.rate + if b.tokens > l.burst { + b.tokens = l.burst + } + b.lastSeen = now + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +func (l *ipRateLimiter) sweepLoop() { + tk := time.NewTicker(l.sweepEvery) + defer tk.Stop() + for { + select { + case <-l.stopCh: + return + case now := <-tk.C: + l.mu.Lock() + for ip, b := range l.buckets { + if now.Sub(b.lastSeen) > l.inactiveTTL { + delete(l.buckets, ip) + } + } + l.mu.Unlock() + } + } +} + +// clientIP extracts the best-effort originating IP. X-Forwarded-For is +// respected but only the leftmost entry is trusted; do not rely on this +// behind untrusted proxies (configure a real reverse proxy for production). +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + if xr := r.Header.Get("X-Real-IP"); xr != "" { + return strings.TrimSpace(xr) + } + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} + +// Package-level singletons (best-effort; DoS hardening is a defense-in-depth +// measure, not a primary security boundary). +var ( + // Allow up to 10 tx submissions/s per IP with a burst of 20. + submitTxLimiter = newIPRateLimiter(10, 20) + // Inbox / contacts polls: allow 20/s per IP with a burst of 40. + readLimiter = newIPRateLimiter(20, 40) +) + +// withSubmitTxGuards composes size + rate-limit protection around apiSubmitTx. +// It rejects oversize bodies and flooding IPs before any JSON decoding happens, +// so an attacker cannot consume CPU decoding 10 MB of nothing. +func withSubmitTxGuards(inner http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + ip := clientIP(r) + if !submitTxLimiter.Allow(ip) { + w.Header().Set("Retry-After", "2") + jsonErr(w, fmt.Errorf("rate limit exceeded"), http.StatusTooManyRequests) + return + } + r.Body = http.MaxBytesReader(w, r.Body, MaxTxRequestBytes) + } + inner.ServeHTTP(w, r) + } +} + +// withReadLimit is the equivalent for inbox / contacts read endpoints. It only +// applies rate limiting (no body size cap needed for GETs). +func withReadLimit(inner http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !readLimiter.Allow(clientIP(r)) { + w.Header().Set("Retry-After", "1") + jsonErr(w, fmt.Errorf("rate limit exceeded"), http.StatusTooManyRequests) + return + } + inner.ServeHTTP(w, r) + } +} + +// ── Access-token gating ────────────────────────────────────────────────────── +// +// Single-node operators often want their node "private" — only they (and +// apps they explicitly configure) can submit transactions or read state +// through its HTTP API. Two flavours: +// +// 1. Semi-public: anyone can GET chain state (netstats, blocks, txs), +// but only clients with a valid Bearer token can POST /api/tx or +// send WS `submit_tx`. Default when `--api-token` is set. +// +// 2. Fully private: EVERY endpoint requires the token. Use `--api-private` +// together with `--api-token`. Good for a personal node whose data +// the operator considers sensitive (e.g. who they're chatting with). +// +// Both modes gate via a shared secret (HTTP `Authorization: Bearer <token>` +// or WS hello-time check). There's no multi-user access control here — +// operators wanting role-based auth should front the node with their +// usual reverse-proxy auth (mTLS, OAuth, basicauth). This is the +// "just keep randos off my box" level of access control. + +var ( + // accessToken is the shared secret required by gated endpoints. + // Empty = gating disabled. + accessToken string + // accessPrivate gates READ endpoints too, not just write. Only + // applies when accessToken is non-empty. + accessPrivate bool +) + +// SetAPIAccess configures the token + private-mode flags. Called once at +// node startup. Pass empty token to disable gating entirely (public node). +func SetAPIAccess(token string, private bool) { + accessToken = strings.TrimSpace(token) + accessPrivate = private +} + +// checkAccessToken returns nil if the request carries the configured +// Bearer token, or an error describing the problem. When accessToken +// is empty (public node) it always returns nil. +// +// Accepts `Authorization: Bearer <token>` or the `?token=<token>` query +// parameter — the latter lets operators open `https://node.example.com/ +// api/netstats?token=...` directly in a browser while testing a private +// node without needing custom headers. +func checkAccessToken(r *http.Request) error { + if accessToken == "" { + return nil + } + // Header takes precedence. + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + if strings.TrimPrefix(auth, "Bearer ") == accessToken { + return nil + } + return fmt.Errorf("invalid bearer token") + } + if q := r.URL.Query().Get("token"); q != "" { + if q == accessToken { + return nil + } + return fmt.Errorf("invalid ?token= query param") + } + return fmt.Errorf("missing bearer token; pass Authorization: Bearer <token> or ?token=...") +} + +// withWriteTokenGuard wraps a write handler (submit tx, etc.) so it +// requires the access token whenever one is configured. Independent of +// accessPrivate — writes are ALWAYS gated when a token is set. +func withWriteTokenGuard(inner http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := checkAccessToken(r); err != nil { + w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`) + jsonErr(w, err, http.StatusUnauthorized) + return + } + inner.ServeHTTP(w, r) + } +} + +// withReadTokenGuard gates a read endpoint. Only enforced when +// accessPrivate is true; otherwise falls through to the inner handler +// so public nodes keep working as before. +func withReadTokenGuard(inner http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if accessToken != "" && accessPrivate { + if err := checkAccessToken(r); err != nil { + w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`) + jsonErr(w, err, http.StatusUnauthorized) + return + } + } + inner.ServeHTTP(w, r) + } +} + +// AccessTokenForWS returns the current token + private-mode flag so the +// WS hub can apply the same policy to websocket submit_tx ops and, when +// private, to connection upgrades themselves. +func AccessTokenForWS() (token string, private bool) { + return accessToken, accessPrivate +} diff --git a/node/api_onboarding.go b/node/api_onboarding.go new file mode 100644 index 0000000..51f6330 --- /dev/null +++ b/node/api_onboarding.go @@ -0,0 +1,162 @@ +// Package node — node-onboarding API routes. +// +// These endpoints let a brand-new node (or a client) discover enough state +// about an existing DChain network to bootstrap itself, without requiring +// the operator to hand-copy validator keys, contract IDs, or peer multiaddrs. +// +// Endpoints: +// +// GET /api/peers → live libp2p peers of this node +// GET /api/network-info → genesis hash + chain id + validators + peers + well-known contracts +// +// Design rationale: +// - /api/peers returns libp2p multiaddrs that include /p2p/<id>. A joiner +// can pass any of these to its own `--peers` flag and immediately dial +// into the DHT/gossipsub mesh. +// - /api/network-info is a ONE-SHOT bootstrap payload. Instead of curling +// six different endpoints, an operator points their new node at a seed +// node's HTTP and pulls everything they need. Fields are optional where +// not applicable so partial responses work even on trimmed-down nodes +// (e.g. non-validator observers). +package node + +import ( + "encoding/json" + "fmt" + "net/http" +) + +func registerOnboardingAPI(mux *http.ServeMux, q ExplorerQuery) { + mux.HandleFunc("/api/peers", apiPeers(q)) + mux.HandleFunc("/api/network-info", apiNetworkInfo(q)) +} + +// apiPeers — GET /api/peers +// +// Returns this node's current view of connected libp2p peers. Empty list is +// a valid response (node is isolated). 503 if the node was built without p2p +// wiring (rare — mostly tests). +func apiPeers(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + if q.ConnectedPeers == nil { + jsonErr(w, fmt.Errorf("p2p not configured on this node"), 503) + return + } + peers := q.ConnectedPeers() + if peers == nil { + peers = []ConnectedPeerRef{} + } + jsonOK(w, map[string]any{ + "count": len(peers), + "peers": peers, + }) + } +} + +// apiNetworkInfo — GET /api/network-info +// +// One-shot bootstrap payload for new joiners. Returns: +// - chain_id — stable network identifier (from ChainID()) +// - genesis_hash — hex hash of block 0; joiners MUST verify a local replay matches this +// - genesis_validator — pubkey of the node that created block 0 +// - tip_height — current committed height (lock-free read) +// - validators — active validator set (pubkey hex) +// - peers — live libp2p peers (for --peers bootstrap list) +// - contracts — well-known contracts by ABI name (same as /api/well-known-contracts) +// - stats — a snapshot of NetStats for a quick sanity check +// +// Any field may be omitted if its query func is nil, so the endpoint +// degrades gracefully on slimmed-down builds. +func apiNetworkInfo(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + out := map[string]any{} + + // --- chain_id --- + if q.ChainID != nil { + out["chain_id"] = q.ChainID() + } + + // --- genesis block --- + if q.GetBlock != nil { + if g, err := q.GetBlock(0); err == nil && g != nil { + out["genesis_hash"] = g.HashHex() + out["genesis_validator"] = g.Validator + out["genesis_time"] = g.Timestamp.UTC().Format("2006-01-02T15:04:05Z") + } + } + + // --- current tip + aggregate stats --- + if q.NetStats != nil { + if s, err := q.NetStats(); err == nil { + out["tip_height"] = s.TotalBlocks + out["stats"] = s + } + } + + // --- active validators --- + if q.ValidatorSet != nil { + if vs, err := q.ValidatorSet(); err == nil { + if vs == nil { + vs = []string{} + } + out["validators"] = vs + } + } + + // --- live peers --- + if q.ConnectedPeers != nil { + peers := q.ConnectedPeers() + if peers == nil { + peers = []ConnectedPeerRef{} + } + out["peers"] = peers + } + + // --- well-known contracts (reuse registerWellKnownAPI's logic) --- + if q.GetContracts != nil { + out["contracts"] = collectWellKnownContracts(q) + } + + jsonOK(w, out) + } +} + +// collectWellKnownContracts is the same reduction used by /api/well-known-contracts +// but inlined here so /api/network-info is a single HTTP round-trip for joiners. +func collectWellKnownContracts(q ExplorerQuery) map[string]WellKnownContract { + out := map[string]WellKnownContract{} + all, err := q.GetContracts() + if err != nil { + return out + } + for _, rec := range all { + if rec.ABIJson == "" { + continue + } + var abi abiHeader + if err := json.Unmarshal([]byte(rec.ABIJson), &abi); err != nil { + continue + } + if abi.Contract == "" { + continue + } + existing, ok := out[abi.Contract] + if !ok || rec.DeployedAt < existing.DeployedAt { + out[abi.Contract] = WellKnownContract{ + ContractID: rec.ContractID, + Name: abi.Contract, + Version: abi.Version, + DeployedAt: rec.DeployedAt, + } + } + } + return out +} diff --git a/node/api_relay.go b/node/api_relay.go new file mode 100644 index 0000000..ac949d6 --- /dev/null +++ b/node/api_relay.go @@ -0,0 +1,320 @@ +package node + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "go-blockchain/blockchain" + "go-blockchain/relay" +) + +// RelayConfig holds dependencies for the relay HTTP API. +type RelayConfig struct { + Mailbox *relay.Mailbox + + // Send seals a message for recipientX25519PubHex and broadcasts it. + // Returns the envelope ID. nil disables POST /relay/send. + Send func(recipientPubHex string, msg []byte) (string, error) + + // Broadcast publishes a pre-sealed Envelope on gossipsub and stores it in the mailbox. + // nil disables POST /relay/broadcast. + Broadcast func(env *relay.Envelope) error + + // ContactRequests returns incoming contact records for the given Ed25519 pubkey. + ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error) +} + +// registerRelayRoutes wires relay mailbox endpoints onto mux. +// +// POST /relay/send {recipient_pub, msg_b64} +// POST /relay/broadcast {envelope: <Envelope JSON>} +// GET /relay/inbox ?pub=<x25519hex>[&since=<unix_ts>][&limit=N] +// GET /relay/inbox/count ?pub=<x25519hex> +// DELETE /relay/inbox/{envID} ?pub=<x25519hex> +// GET /relay/contacts ?pub=<ed25519hex> +func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) { + mux.HandleFunc("/relay/send", relaySend(rc)) + mux.HandleFunc("/relay/broadcast", relayBroadcast(rc)) + mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc)) + mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc)) + mux.HandleFunc("/relay/inbox", relayInboxList(rc)) + mux.HandleFunc("/relay/contacts", relayContacts(rc)) +} + +// relayInboxList handles GET /relay/inbox?pub=<hex>[&since=<ts>][&limit=N] +func relayInboxList(rc RelayConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + pub := r.URL.Query().Get("pub") + if pub == "" { + jsonErr(w, fmt.Errorf("pub parameter required"), 400) + return + } + since := int64(0) + if s := r.URL.Query().Get("since"); s != "" { + if v, err := parseInt64(s); err == nil && v > 0 { + since = v + } + } + limit := queryIntMin0(r, "limit") + if limit == 0 { + limit = 50 + } + + envelopes, err := rc.Mailbox.List(pub, since, limit) + if err != nil { + jsonErr(w, err, 500) + return + } + + type item struct { + ID string `json:"id"` + SenderPub string `json:"sender_pub"` + RecipientPub string `json:"recipient_pub"` + FeeUT uint64 `json:"fee_ut,omitempty"` + SentAt int64 `json:"sent_at"` + SentAtHuman string `json:"sent_at_human"` + Nonce []byte `json:"nonce"` + Ciphertext []byte `json:"ciphertext"` + } + + out := make([]item, 0, len(envelopes)) + for _, env := range envelopes { + out = append(out, item{ + ID: env.ID, + SenderPub: env.SenderPub, + RecipientPub: env.RecipientPub, + FeeUT: env.FeeUT, + SentAt: env.SentAt, + SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339), + Nonce: env.Nonce, + Ciphertext: env.Ciphertext, + }) + } + + hasMore := len(out) == limit + jsonOK(w, map[string]any{ + "pub": pub, + "count": len(out), + "has_more": hasMore, + "items": out, + }) + } +} + +// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<hex> +func relayInboxDelete(rc RelayConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + // Also serve GET /relay/inbox/{id} for convenience (fetch single envelope) + if r.Method == http.MethodGet { + relayInboxList(rc)(w, r) + return + } + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + + envID := strings.TrimPrefix(r.URL.Path, "/relay/inbox/") + if envID == "" { + jsonErr(w, fmt.Errorf("envelope ID required in path"), 400) + return + } + pub := r.URL.Query().Get("pub") + if pub == "" { + jsonErr(w, fmt.Errorf("pub parameter required"), 400) + return + } + + if err := rc.Mailbox.Delete(pub, envID); err != nil { + jsonErr(w, err, 500) + return + } + jsonOK(w, map[string]string{"id": envID, "status": "deleted"}) + } +} + +// relayInboxCount handles GET /relay/inbox/count?pub=<hex> +func relayInboxCount(rc RelayConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + pub := r.URL.Query().Get("pub") + if pub == "" { + jsonErr(w, fmt.Errorf("pub parameter required"), 400) + return + } + count, err := rc.Mailbox.Count(pub) + if err != nil { + jsonErr(w, err, 500) + return + } + jsonOK(w, map[string]any{"pub": pub, "count": count}) + } +} + +// relaySend handles POST /relay/send +// +// Request body: +// +// { +// "recipient_pub": "<hex X25519 pub key>", +// "msg_b64": "<base64-encoded plaintext>", +// } +// +// The relay node seals the message using its own X25519 keypair and broadcasts +// it on the relay gossipsub topic. No on-chain fee is attached — delivery is +// free for light clients using this endpoint. +func relaySend(rc RelayConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + if rc.Send == nil { + jsonErr(w, fmt.Errorf("relay send not available on this node"), 503) + return + } + + var req struct { + RecipientPub string `json:"recipient_pub"` + MsgB64 string `json:"msg_b64"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400) + return + } + if req.RecipientPub == "" { + jsonErr(w, fmt.Errorf("recipient_pub is required"), 400) + return + } + if req.MsgB64 == "" { + jsonErr(w, fmt.Errorf("msg_b64 is required"), 400) + return + } + + msg, err := decodeBase64(req.MsgB64) + if err != nil { + jsonErr(w, fmt.Errorf("msg_b64: %w", err), 400) + return + } + if len(msg) == 0 { + jsonErr(w, fmt.Errorf("msg_b64: empty message"), 400) + return + } + + envID, err := rc.Send(req.RecipientPub, msg) + if err != nil { + jsonErr(w, fmt.Errorf("send failed: %w", err), 500) + return + } + jsonOK(w, map[string]string{ + "id": envID, + "recipient_pub": req.RecipientPub, + "status": "sent", + }) + } +} + +// decodeBase64 accepts both standard and URL-safe base64. +func decodeBase64(s string) ([]byte, error) { + // Try URL-safe first (no padding required), then standard. + if b, err := base64.RawURLEncoding.DecodeString(s); err == nil { + return b, nil + } + return base64.StdEncoding.DecodeString(s) +} + +// relayBroadcast handles POST /relay/broadcast +// +// Request body: {"envelope": <relay.Envelope JSON>} +// +// Light clients use this to publish pre-sealed envelopes without a direct +// libp2p connection. The relay node stores it in the mailbox and gossips it. +func relayBroadcast(rc RelayConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + if rc.Broadcast == nil { + jsonErr(w, fmt.Errorf("relay broadcast not available on this node"), 503) + return + } + + var req struct { + Envelope *relay.Envelope `json:"envelope"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400) + return + } + if req.Envelope == nil { + jsonErr(w, fmt.Errorf("envelope is required"), 400) + return + } + if req.Envelope.ID == "" { + jsonErr(w, fmt.Errorf("envelope.id is required"), 400) + return + } + if len(req.Envelope.Ciphertext) == 0 { + jsonErr(w, fmt.Errorf("envelope.ciphertext is required"), 400) + return + } + + if err := rc.Broadcast(req.Envelope); err != nil { + jsonErr(w, fmt.Errorf("broadcast failed: %w", err), 500) + return + } + jsonOK(w, map[string]string{ + "id": req.Envelope.ID, + "status": "broadcast", + }) + } +} + +// relayContacts handles GET /relay/contacts?pub=<ed25519hex> +// +// Returns all incoming contact requests for the given Ed25519 public key. +func relayContacts(rc RelayConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + if rc.ContactRequests == nil { + jsonErr(w, fmt.Errorf("contacts not available on this node"), 503) + return + } + pub := r.URL.Query().Get("pub") + if pub == "" { + jsonErr(w, fmt.Errorf("pub parameter required"), 400) + return + } + + contacts, err := rc.ContactRequests(pub) + if err != nil { + jsonErr(w, err, 500) + return + } + jsonOK(w, map[string]any{ + "pub": pub, + "count": len(contacts), + "contacts": contacts, + }) + } +} + +// parseInt64 parses a string as int64. +func parseInt64(s string) (int64, error) { + var v int64 + if err := json.Unmarshal([]byte(s), &v); err != nil { + return 0, err + } + return v, nil +} diff --git a/node/api_routes.go b/node/api_routes.go new file mode 100644 index 0000000..7564338 --- /dev/null +++ b/node/api_routes.go @@ -0,0 +1,270 @@ +// Package node - chain explorer HTTP API and minimal web UI. +package node + +import ( + "net/http" + + "go-blockchain/blockchain" +) + +// ConnectedPeerRef is an entry returned by /api/peers — one currently-connected +// libp2p peer. Mirrors p2p.ConnectedConnectedPeerRef but kept in the node package +// so api routes don't pull the p2p package directly. +type ConnectedPeerRef struct { + ID string `json:"id"` + Addrs []string `json:"addrs"` + // Version is the peer's last-seen version announce (from gossipsub topic + // dchain/version/v1). Empty when the peer hasn't announced yet — either + // it's running an older binary without gossip, or it hasn't reached its + // first publish tick (up to 60s after connect). + Version *PeerVersionRef `json:"version,omitempty"` +} + +// PeerVersionRef mirrors p2p.PeerVersion in a package-local type so +// api_routes doesn't import p2p directly. +type PeerVersionRef struct { + Tag string `json:"tag"` + Commit string `json:"commit"` + ProtocolVersion int `json:"protocol_version"` + Timestamp int64 `json:"timestamp"` + ReceivedAt string `json:"received_at,omitempty"` +} + +// NativeContractInfo is the shape ExplorerQuery.NativeContracts returns. +// Passed from main.go (which has the blockchain package imported) to the +// well-known endpoint below so it can merge native contracts with WASM ones. +type NativeContractInfo struct { + ContractID string + ABIJson string +} + +// ExplorerQuery holds all functions the explorer API needs to read chain state +// and accept new transactions. +type ExplorerQuery struct { + GetBlock func(index uint64) (*blockchain.Block, error) + GetTx func(txID string) (*blockchain.TxRecord, error) + AddressToPubKey func(addr string) (string, error) + Balance func(pubKey string) (uint64, error) + Reputation func(pubKey string) (blockchain.RepStats, error) + WalletBinding func(pubKey string) (string, error) + TxsByAddress func(pubKey string, limit, offset int) ([]*blockchain.TxRecord, error) + RecentBlocks func(limit int) ([]*blockchain.Block, error) + RecentTxs func(limit int) ([]*blockchain.TxRecord, error) + NetStats func() (blockchain.NetStats, error) + RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error) + IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) + ValidatorSet func() ([]string, error) + SubmitTx func(tx *blockchain.Transaction) error + // ConnectedPeers (optional) returns the local libp2p view of currently + // connected peers. Used by /api/peers and /api/network-info so new nodes + // can bootstrap from any existing peer's view of the network. May be nil + // if the node binary is built without p2p (tests). + ConnectedPeers func() []ConnectedPeerRef + // ChainID (optional) returns a stable identifier for this chain so a + // joiner can sanity-check it's syncing from the right network. + ChainID func() string + // NativeContracts (optional) returns the list of built-in Go contracts + // registered on this node. These appear in /api/well-known-contracts + // alongside WASM contracts, so the client doesn't need to distinguish. + NativeContracts func() []NativeContractInfo + GetContract func(contractID string) (*blockchain.ContractRecord, error) + GetContracts func() ([]blockchain.ContractRecord, error) + GetContractState func(contractID, key string) ([]byte, error) + GetContractLogs func(contractID string, limit int) ([]blockchain.ContractLogEntry, error) + Stake func(pubKey string) (uint64, error) + GetToken func(tokenID string) (*blockchain.TokenRecord, error) + GetTokens func() ([]blockchain.TokenRecord, error) + TokenBalance func(tokenID, pubKey string) (uint64, error) + GetNFT func(nftID string) (*blockchain.NFTRecord, error) + GetNFTs func() ([]blockchain.NFTRecord, error) + NFTsByOwner func(ownerPub string) ([]blockchain.NFTRecord, error) + + // Channel group-messaging lookups (R1). GetChannel returns metadata; + // GetChannelMembers returns the Ed25519 pubkey of every current member. + // Both may be nil on nodes that don't expose channel state (tests). + GetChannel func(channelID string) (*blockchain.CreateChannelPayload, error) + GetChannelMembers func(channelID string) ([]string, error) + + // Events is the SSE hub for the live event stream. Optional — if nil the + // /api/events endpoint returns 501 Not Implemented. + Events *SSEHub + // WS is the websocket hub for low-latency push to mobile/desktop clients. + // Optional — if nil the /api/ws endpoint returns 501. + WS *WSHub +} + +// ExplorerRouteFlags toggles the optional HTML frontend surfaces. API +// endpoints (/api/*) always register — these flags only affect static pages. +type ExplorerRouteFlags struct { + // DisableUI suppresses the embedded block explorer at `/`, `/address`, + // `/tx`, `/node`, `/relays`, `/validators`, `/contract`, `/tokens`, + // `/token`, and their `/assets/explorer/*.js|css` dependencies. Useful + // for JSON-API-only deployments (headless nodes, mobile-backend nodes). + DisableUI bool + + // DisableSwagger suppresses `/swagger` and `/swagger/openapi.json`. + // Useful for hardened private deployments where even API documentation + // shouldn't be exposed. Does NOT affect the JSON API itself. + DisableSwagger bool +} + +// RegisterExplorerRoutes adds all explorer API, chain API and docs routes to mux. +// The variadic flags parameter is optional — passing none (or an empty struct) +// registers the full surface (UI + Swagger + JSON API) for backwards compatibility. +func RegisterExplorerRoutes(mux *http.ServeMux, q ExplorerQuery, flags ...ExplorerRouteFlags) { + var f ExplorerRouteFlags + if len(flags) > 0 { + f = flags[0] + } + if !f.DisableUI { + registerExplorerPages(mux) + } + registerExplorerAPI(mux, q) + registerChainAPI(mux, q) + registerContractAPI(mux, q) + registerWellKnownAPI(mux, q) + registerWellKnownVersionAPI(mux, q) + registerUpdateCheckAPI(mux, q) + registerOnboardingAPI(mux, q) + registerTokenAPI(mux, q) + registerChannelAPI(mux, q) + if !f.DisableSwagger { + registerSwaggerRoutes(mux) + } +} + +// RegisterRelayRoutes adds relay mailbox HTTP endpoints to mux. +// Call this after RegisterExplorerRoutes with the relay config. +func RegisterRelayRoutes(mux *http.ServeMux, rc RelayConfig) { + if rc.Mailbox == nil { + return + } + registerRelayRoutes(mux, rc) +} + +func registerExplorerPages(mux *http.ServeMux) { + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + serveExplorerIndex(w, r) + }) + mux.HandleFunc("/address", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/address" { + http.NotFound(w, r) + return + } + serveExplorerAddressPage(w, r) + }) + mux.HandleFunc("/tx", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/tx" { + http.NotFound(w, r) + return + } + serveExplorerTxPage(w, r) + }) + mux.HandleFunc("/node", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/node" { + http.NotFound(w, r) + return + } + serveExplorerNodePage(w, r) + }) + mux.HandleFunc("/relays", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/relays" { + http.NotFound(w, r) + return + } + serveExplorerRelaysPage(w, r) + }) + mux.HandleFunc("/validators", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/validators" { + http.NotFound(w, r) + return + } + serveExplorerValidatorsPage(w, r) + }) + mux.HandleFunc("/assets/explorer/style.css", serveExplorerCSS) + mux.HandleFunc("/assets/explorer/common.js", serveExplorerCommonJS) + mux.HandleFunc("/assets/explorer/app.js", serveExplorerJS) + mux.HandleFunc("/assets/explorer/address.js", serveExplorerAddressJS) + mux.HandleFunc("/assets/explorer/tx.js", serveExplorerTxJS) + mux.HandleFunc("/assets/explorer/node.js", serveExplorerNodeJS) + mux.HandleFunc("/assets/explorer/relays.js", serveExplorerRelaysJS) + mux.HandleFunc("/assets/explorer/validators.js", serveExplorerValidatorsJS) + mux.HandleFunc("/contract", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/contract" { + http.NotFound(w, r) + return + } + serveExplorerContractPage(w, r) + }) + mux.HandleFunc("/assets/explorer/contract.js", serveExplorerContractJS) + mux.HandleFunc("/tokens", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/tokens" { + http.NotFound(w, r) + return + } + serveExplorerTokensPage(w, r) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/token" { + http.NotFound(w, r) + return + } + serveExplorerTokenPage(w, r) + }) + mux.HandleFunc("/assets/explorer/tokens.js", serveExplorerTokensJS) + mux.HandleFunc("/assets/explorer/token.js", serveExplorerTokenJS) +} + +func registerExplorerAPI(mux *http.ServeMux, q ExplorerQuery) { + mux.HandleFunc("/api/netstats", apiNetStats(q)) + mux.HandleFunc("/api/blocks", apiRecentBlocks(q)) + mux.HandleFunc("/api/txs/recent", apiRecentTxs(q)) // GET /api/txs/recent?limit=20 + mux.HandleFunc("/api/block/", apiBlock(q)) // GET /api/block/{index} + mux.HandleFunc("/api/tx/", apiTxByID(q)) // GET /api/tx/{txid} + mux.HandleFunc("/api/address/", apiAddress(q)) // GET /api/address/{addr} + mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...} + mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays + mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr} + mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators + mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate) + // Live event stream (SSE) — GET /api/events + mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { + if q.Events == nil { + http.Error(w, "event stream not available", http.StatusNotImplemented) + return + } + q.Events.ServeHTTP(w, r) + }) + // WebSocket gateway — GET /api/ws (upgrades to ws://). Low-latency push + // for clients that would otherwise poll balance/inbox/contacts. + mux.HandleFunc("/api/ws", func(w http.ResponseWriter, r *http.Request) { + if q.WS == nil { + http.Error(w, "websocket not available", http.StatusNotImplemented) + return + } + q.WS.ServeHTTP(w, r) + }) + // Prometheus exposition endpoint — scraped by a Prometheus server. Has + // no auth; operators running this in public should put the node behind + // a reverse proxy that restricts /metrics to trusted scrapers only. + mux.HandleFunc("/metrics", metricsHandler) +} + +func registerTokenAPI(mux *http.ServeMux, q ExplorerQuery) { + mux.HandleFunc("/api/tokens", apiTokens(q)) // GET /api/tokens + mux.HandleFunc("/api/tokens/", apiTokenByID(q)) // GET /api/tokens/{id} and /api/tokens/{id}/balance/{pubkey} + mux.HandleFunc("/api/nfts", apiNFTs(q)) // GET /api/nfts + mux.HandleFunc("/api/nfts/", apiNFTByID(q)) // GET /api/nfts/{id} and /api/nfts/owner/{pubkey} +} + +func registerChainAPI(mux *http.ServeMux, q ExplorerQuery) { + // Similar to blockchain APIs, but only with features supported by this chain. + mux.HandleFunc("/v2/chain/accounts/", apiV2ChainAccountTransactions(q)) // GET /v2/chain/accounts/{account_id}/transactions + mux.HandleFunc("/v2/chain/transactions/", apiV2ChainTxByID(q)) // GET /v2/chain/transactions/{tx_id} + mux.HandleFunc("/v2/chain/transactions/draft", apiV2ChainDraftTx()) // POST /v2/chain/transactions/draft + mux.HandleFunc("/v2/chain/transactions", withWriteTokenGuard(withSubmitTxGuards(apiV2ChainSendTx(q)))) // POST /v2/chain/transactions (body size + rate limit + optional token gate) +} diff --git a/node/api_tokens.go b/node/api_tokens.go new file mode 100644 index 0000000..af9ced7 --- /dev/null +++ b/node/api_tokens.go @@ -0,0 +1,183 @@ +package node + +import ( + "fmt" + "net/http" + "strings" + + "go-blockchain/blockchain" + "go-blockchain/wallet" +) + +// GET /api/nfts — list all NFTs. +func apiNFTs(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if q.GetNFTs == nil { + jsonErr(w, fmt.Errorf("NFT query not available"), 503) + return + } + nfts, err := q.GetNFTs() + if err != nil { + jsonErr(w, err, 500) + return + } + if nfts == nil { + nfts = []blockchain.NFTRecord{} + } + jsonOK(w, map[string]any{ + "count": len(nfts), + "nfts": nfts, + }) + } +} + +// GET /api/nfts/{id} — single NFT metadata +// GET /api/nfts/owner/{pubkey} — NFTs owned by address +func apiNFTByID(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/nfts/") + parts := strings.SplitN(path, "/", 2) + + // /api/nfts/owner/{pubkey} + if parts[0] == "owner" { + if len(parts) < 2 || parts[1] == "" { + jsonErr(w, fmt.Errorf("pubkey required"), 400) + return + } + pubKey := parts[1] + if strings.HasPrefix(pubKey, "DC") && q.AddressToPubKey != nil { + if pk, err := q.AddressToPubKey(pubKey); err == nil && pk != "" { + pubKey = pk + } + } + if q.NFTsByOwner == nil { + jsonErr(w, fmt.Errorf("NFT query not available"), 503) + return + } + nfts, err := q.NFTsByOwner(pubKey) + if err != nil { + jsonErr(w, err, 500) + return + } + if nfts == nil { + nfts = []blockchain.NFTRecord{} + } + jsonOK(w, map[string]any{"count": len(nfts), "nfts": nfts}) + return + } + + // /api/nfts/{id} + nftID := parts[0] + if nftID == "" { + jsonErr(w, fmt.Errorf("NFT ID required"), 400) + return + } + if q.GetNFT == nil { + jsonErr(w, fmt.Errorf("NFT query not available"), 503) + return + } + rec, err := q.GetNFT(nftID) + if err != nil { + jsonErr(w, err, 500) + return + } + if rec == nil { + jsonErr(w, fmt.Errorf("NFT %s not found", nftID), 404) + return + } + // Attach owner address for convenience. + ownerAddr := "" + if rec.Owner != "" { + ownerAddr = wallet.PubKeyToAddress(rec.Owner) + } + jsonOK(w, map[string]any{ + "nft": rec, + "owner_address": ownerAddr, + }) + } +} + +// GET /api/tokens — list all issued tokens. +func apiTokens(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if q.GetTokens == nil { + jsonErr(w, fmt.Errorf("token query not available"), 503) + return + } + tokens, err := q.GetTokens() + if err != nil { + jsonErr(w, err, 500) + return + } + if tokens == nil { + tokens = []blockchain.TokenRecord{} + } + jsonOK(w, map[string]any{ + "count": len(tokens), + "tokens": tokens, + }) + } +} + +// GET /api/tokens/{id} — token metadata +// GET /api/tokens/{id}/balance/{pub} — token balance for a public key or DC address +func apiTokenByID(q ExplorerQuery) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/tokens/") + parts := strings.SplitN(path, "/", 3) + + tokenID := parts[0] + if tokenID == "" { + jsonErr(w, fmt.Errorf("token ID required"), 400) + return + } + + // /api/tokens/{id}/balance/{pub} + if len(parts) == 3 && parts[1] == "balance" { + pubOrAddr := parts[2] + if pubOrAddr == "" { + jsonErr(w, fmt.Errorf("pubkey or address required"), 400) + return + } + // Resolve DC address to pubkey if needed. + pubKey := pubOrAddr + if strings.HasPrefix(pubOrAddr, "DC") && q.AddressToPubKey != nil { + if pk, err := q.AddressToPubKey(pubOrAddr); err == nil && pk != "" { + pubKey = pk + } + } + if q.TokenBalance == nil { + jsonErr(w, fmt.Errorf("token balance query not available"), 503) + return + } + bal, err := q.TokenBalance(tokenID, pubKey) + if err != nil { + jsonErr(w, err, 500) + return + } + jsonOK(w, map[string]any{ + "token_id": tokenID, + "pub_key": pubKey, + "address": wallet.PubKeyToAddress(pubKey), + "balance": bal, + }) + return + } + + // /api/tokens/{id} + if q.GetToken == nil { + jsonErr(w, fmt.Errorf("token query not available"), 503) + return + } + rec, err := q.GetToken(tokenID) + if err != nil { + jsonErr(w, err, 500) + return + } + if rec == nil { + jsonErr(w, fmt.Errorf("token %s not found", tokenID), 404) + return + } + jsonOK(w, rec) + } +} diff --git a/node/api_update_check.go b/node/api_update_check.go new file mode 100644 index 0000000..69e5368 --- /dev/null +++ b/node/api_update_check.go @@ -0,0 +1,201 @@ +// Package node — /api/update-check endpoint. +// +// What this does +// ────────────── +// Polls the configured release source (typically a Gitea `/api/v1/repos/ +// {owner}/{repo}/releases/latest` URL) and compares its answer with the +// binary's own build-time version. Returns: +// +// { +// "current": { "tag": "v0.5.0", "commit": "abc1234", "date": "..." }, +// "latest": { "tag": "v0.5.1", "commit": "def5678", "url": "...", "published_at": "..." }, +// "update_available": true, +// "checked_at": "2026-04-17T09:45:12Z" +// } +// +// When unconfigured (empty DCHAIN_UPDATE_SOURCE_URL), responds 503 with a +// hint pointing at the env var. Never blocks for more than ~5 seconds +// (HTTP timeout); upstream failures are logged and surfaced as 502. +// +// Cache +// ───── +// Hammering a public Gitea instance every time an operator curls this +// endpoint would be rude. We cache the latest-release lookup for 15 minutes +// in memory. The update script in deploy/single/update.sh honors this cache +// by reading /api/update-check once per run — so a typical hourly timer + +// 15-min cache means at most 4 upstream hits per node per hour, usually 1. +// +// Configuration +// ───────────── +// DCHAIN_UPDATE_SOURCE_URL — full URL of the Gitea latest-release API. +// Example: +// https://gitea.example.com/api/v1/repos/dchain/dchain/releases/latest +// DCHAIN_UPDATE_SOURCE_TOKEN — optional Gitea PAT for private repos. +// +// These are read by cmd/node/main.go and handed to SetUpdateSource below. +package node + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "go-blockchain/node/version" +) + +const ( + updateCacheTTL = 15 * time.Minute + updateTimeout = 5 * time.Second +) + +var ( + updateMu sync.RWMutex + updateSourceURL string + updateSourceToken string + updateCache *updateCheckResponse + updateCacheAt time.Time +) + +// SetUpdateSource configures where /api/update-check should poll. Called +// once at node startup from cmd/node/main.go after flag parsing. Empty url +// leaves the endpoint in "unconfigured" mode (503). +func SetUpdateSource(url, token string) { + updateMu.Lock() + defer updateMu.Unlock() + updateSourceURL = url + updateSourceToken = token + // Clear cache on config change (mostly a test-time concern). + updateCache = nil + updateCacheAt = time.Time{} +} + +// giteaRelease is the subset of the Gitea release JSON we care about. +// Gitea's schema is a superset of GitHub's, so the same shape works for +// github.com/api/v3 too — operators can point at either. +type giteaRelease struct { + TagName string `json:"tag_name"` + TargetCommit string `json:"target_commitish"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +type updateCheckResponse struct { + Current map[string]string `json:"current"` + Latest *latestRef `json:"latest,omitempty"` + UpdateAvailable bool `json:"update_available"` + CheckedAt string `json:"checked_at"` + Source string `json:"source,omitempty"` +} + +type latestRef struct { + Tag string `json:"tag"` + Commit string `json:"commit,omitempty"` + URL string `json:"url,omitempty"` + PublishedAt string `json:"published_at,omitempty"` +} + +func registerUpdateCheckAPI(mux *http.ServeMux, q ExplorerQuery) { + mux.HandleFunc("/api/update-check", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + + updateMu.RLock() + src := updateSourceURL + tok := updateSourceToken + cached := updateCache + cachedAt := updateCacheAt + updateMu.RUnlock() + + if src == "" { + jsonErr(w, + fmt.Errorf("update source not configured — set DCHAIN_UPDATE_SOURCE_URL to a Gitea /api/v1/repos/{owner}/{repo}/releases/latest URL"), + http.StatusServiceUnavailable) + return + } + + // Serve fresh cache without hitting upstream. + if cached != nil && time.Since(cachedAt) < updateCacheTTL { + jsonOK(w, cached) + return + } + + // Fetch upstream. + latest, err := fetchLatestRelease(r.Context(), src, tok) + if err != nil { + jsonErr(w, fmt.Errorf("upstream check failed: %w", err), http.StatusBadGateway) + return + } + + resp := &updateCheckResponse{ + Current: version.Info(), + CheckedAt: time.Now().UTC().Format(time.RFC3339), + Source: src, + } + if latest != nil { + resp.Latest = &latestRef{ + Tag: latest.TagName, + Commit: latest.TargetCommit, + URL: latest.HTMLURL, + PublishedAt: latest.PublishedAt, + } + resp.UpdateAvailable = latest.TagName != "" && + latest.TagName != version.Tag && + !latest.Draft && + !latest.Prerelease + } + + updateMu.Lock() + updateCache = resp + updateCacheAt = time.Now() + updateMu.Unlock() + + jsonOK(w, resp) + }) +} + +// fetchLatestRelease performs the actual HTTP call with a short timeout. +// Returns nil, nil when the upstream replies 404 (no releases yet) — this +// is not an error condition, the operator just hasn't published anything +// yet. All other non-2xx are returned as errors. +func fetchLatestRelease(ctx interface{ Deadline() (time.Time, bool) }, url, token string) (*giteaRelease, error) { + // We only use ctx for cancellation type-matching; the actual deadline + // comes from updateTimeout below. Tests pass a context.Background(). + _ = ctx + + client := &http.Client{Timeout: updateTimeout} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "dchain-node/"+version.Tag) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil // no releases yet + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(b)) + } + var rel giteaRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &rel, nil +} diff --git a/node/api_well_known.go b/node/api_well_known.go new file mode 100644 index 0000000..b663ec4 --- /dev/null +++ b/node/api_well_known.go @@ -0,0 +1,116 @@ +// Package node — /api/well-known-contracts endpoint. +// +// This endpoint lets a freshly-launched client auto-discover the canonical +// contract IDs for system services (username registry, governance, …) without +// the user having to paste contract IDs into settings by hand. +// +// Discovery strategy: +// +// 1. List all deployed contracts via q.GetContracts(). +// 2. For each contract, parse its ABI JSON and pull the "contract" name field. +// 3. For each distinct name, keep the **earliest-deployed** record — this is +// the canonical one. Operators who want to override this (e.g. migrate to +// a new registry) will add pinning support via config later; for the MVP +// "earliest wins" matches what all nodes see because the chain is ordered. +// +// The response shape is stable JSON so the client can rely on it: +// +// { +// "count": 3, +// "contracts": { +// "username_registry": { "contract_id": "…", "name": "username_registry", "version": "1.0.0", "deployed_at": 42 }, +// "governance": { "contract_id": "…", "name": "governance", "version": "0.9.0", "deployed_at": 50 }, +// … +// } +// } +package node + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// WellKnownContract is the per-entry payload returned in /api/well-known-contracts. +type WellKnownContract struct { + ContractID string `json:"contract_id"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + DeployedAt uint64 `json:"deployed_at"` +} + +// abiHeader is the minimal subset of a contract's ABI JSON we need to look at. +type abiHeader struct { + Contract string `json:"contract"` + Version string `json:"version"` +} + +func registerWellKnownAPI(mux *http.ServeMux, q ExplorerQuery) { + mux.HandleFunc("/api/well-known-contracts", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + if q.GetContracts == nil { + jsonErr(w, fmt.Errorf("contract queries not available on this node"), 503) + return + } + all, err := q.GetContracts() + if err != nil { + jsonErr(w, err, 500) + return + } + + out := map[string]WellKnownContract{} + + // WASM contracts (stored as ContractRecord in BadgerDB). + for _, rec := range all { + if rec.ABIJson == "" { + continue + } + var abi abiHeader + if err := json.Unmarshal([]byte(rec.ABIJson), &abi); err != nil { + continue + } + if abi.Contract == "" { + continue + } + existing, ok := out[abi.Contract] + if !ok || rec.DeployedAt < existing.DeployedAt { + out[abi.Contract] = WellKnownContract{ + ContractID: rec.ContractID, + Name: abi.Contract, + Version: abi.Version, + DeployedAt: rec.DeployedAt, + } + } + } + + // Native (in-process Go) contracts. These always win over WASM + // equivalents of the same ABI name — the native implementation is + // authoritative because every node runs identical Go code, while a + // WASM copy might drift (different build, different bytecode). + if q.NativeContracts != nil { + for _, nc := range q.NativeContracts() { + var abi abiHeader + if err := json.Unmarshal([]byte(nc.ABIJson), &abi); err != nil { + continue + } + if abi.Contract == "" { + continue + } + out[abi.Contract] = WellKnownContract{ + ContractID: nc.ContractID, + Name: abi.Contract, + Version: abi.Version, + DeployedAt: 0, // native contracts exist from block 0 + } + } + } + + jsonOK(w, map[string]any{ + "count": len(out), + "contracts": out, + }) + }) +} diff --git a/node/api_well_known_version.go b/node/api_well_known_version.go new file mode 100644 index 0000000..dd7edec --- /dev/null +++ b/node/api_well_known_version.go @@ -0,0 +1,93 @@ +// Package node — /api/well-known-version endpoint. +// +// Clients hit this to feature-detect the node they're talking to, without +// hardcoding "node >= 0.5 supports channels" into every screen. The response +// lists three coordinates a client cares about: +// +// - node_version — human-readable build tag (ldflags-injectable) +// - protocol_version — integer bumped only on wire-protocol breaking changes +// - features — stable string tags for "this binary implements X" +// +// Feature tags are ADDITIVE — once a tag ships in a release, it keeps being +// returned forever (even if the implementation moves around internally). The +// client uses them as "is this feature here or not?", not "what version is +// this feature at?". Versioning a feature is done by shipping a new tag +// (e.g. "channels_v2" alongside "channels_v1" for a deprecation window). +// +// Response shape: +// +// { +// "node_version": "0.5.0-dev", +// "protocol_version": 1, +// "features": [ +// "channels_v1", +// "fan_out", +// "native_username_registry", +// "ws_submit_tx", +// "access_token" +// ], +// "chain_id": "dchain-ddb9a7e37fc8" +// } +package node + +import ( + "fmt" + "net/http" + "sort" + + "go-blockchain/node/version" +) + +// ProtocolVersion is bumped only when the wire-protocol changes in a way +// that a client compiled against version N cannot talk to a node at +// version N+1 (or vice versa) without updating. Adding new optional fields, +// new EventTypes, new WS ops, new HTTP endpoints — none of those bump this. +// +// Bumping this means a coordinated release: every client and every node +// operator must update before the old version stops working. +const ProtocolVersion = 1 + +// nodeFeatures is the baked-in list of feature tags this binary implements. +// Append-only. When you add a new tag, add it here AND document what it means +// so clients can feature-detect reliably. +// +// Naming convention: snake_case, versioned suffix for anything that might get +// a breaking successor (e.g. `channels_v1`, not `channels`). +var nodeFeatures = []string{ + "access_token", // DCHAIN_API_TOKEN gating on writes (+ optional reads) + "channels_v1", // /api/channels/:id + /members with X25519 enrichment + "chain_id", // /api/network-info returns chain_id + "contract_logs", // /api/contract/:id/logs endpoint + "fan_out", // client-side per-recipient envelope sealing + "identity_registry", // /api/identity/:pub returns X25519 pub + relay hints + "native_username_registry", // native:username_registry contract + "onboarding_api", // /api/network-info for joiner bootstrap + "payment_channels", // off-chain payment channel open/close + "relay_mailbox", // /relay/send + /relay/inbox + "ws_submit_tx", // WebSocket submit_tx op +} + +func registerWellKnownVersionAPI(mux *http.ServeMux, q ExplorerQuery) { + mux.HandleFunc("/api/well-known-version", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, fmt.Errorf("method not allowed"), 405) + return + } + // Return a copy so callers can't mutate the shared slice. + feats := make([]string, len(nodeFeatures)) + copy(feats, nodeFeatures) + sort.Strings(feats) + + resp := map[string]any{ + "node_version": version.Tag, + "build": version.Info(), + "protocol_version": ProtocolVersion, + "features": feats, + } + // Include chain_id if the node exposes it (same helper as network-info). + if q.ChainID != nil { + resp["chain_id"] = q.ChainID() + } + jsonOK(w, resp) + }) +} diff --git a/node/events.go b/node/events.go new file mode 100644 index 0000000..5e36c5a --- /dev/null +++ b/node/events.go @@ -0,0 +1,119 @@ +// Package node — unified event bus for SSE, WebSocket, and any future +// subscriber of block / tx / contract-log / inbox events. +// +// Before this file, emit code was duplicated at every commit callsite: +// +// go sseHub.EmitBlockWithTxs(b) +// go wsHub.EmitBlockWithTxs(b) +// go emitContractLogs(sseHub, wsHub, chain, b) +// +// With the bus, callers do one thing: +// +// go bus.EmitBlockWithTxs(b) +// +// Adding a new subscriber (metrics sampler, WAL replicator, IPFS mirror…) +// means registering once at startup — no edits at every call site. +package node + +import ( + "encoding/json" + + "go-blockchain/blockchain" +) + +// EventConsumer is what the bus calls for each event. Implementations are +// registered once at startup via Bus.Register; fanout happens inside +// Emit* methods. +// +// Methods may be called from multiple goroutines concurrently — consumers +// must be safe for concurrent use. +type EventConsumer interface { + OnBlock(*blockchain.Block) + OnTx(*blockchain.Transaction) + OnContractLog(blockchain.ContractLogEntry) + OnInbox(recipientX25519 string, summary json.RawMessage) +} + +// EventBus fans events out to every registered consumer. Zero value is a +// valid empty bus (Emit* are no-ops until someone Register()s). +type EventBus struct { + consumers []EventConsumer +} + +// NewEventBus returns a fresh bus with no consumers. +func NewEventBus() *EventBus { return &EventBus{} } + +// Register appends a consumer. Not thread-safe — call once at startup +// before any Emit* is invoked. +func (b *EventBus) Register(c EventConsumer) { + b.consumers = append(b.consumers, c) +} + +// EmitBlock notifies every consumer of a freshly-committed block. +// Does NOT iterate transactions — use EmitBlockWithTxs for that. +func (b *EventBus) EmitBlock(blk *blockchain.Block) { + for _, c := range b.consumers { + c.OnBlock(blk) + } +} + +// EmitTx notifies every consumer of a single committed transaction. +// Synthetic BLOCK_REWARD records are skipped by the implementations that +// care (SSE already filters); the bus itself doesn't second-guess. +func (b *EventBus) EmitTx(tx *blockchain.Transaction) { + for _, c := range b.consumers { + c.OnTx(tx) + } +} + +// EmitContractLog notifies every consumer of a contract log entry. +func (b *EventBus) EmitContractLog(entry blockchain.ContractLogEntry) { + for _, c := range b.consumers { + c.OnContractLog(entry) + } +} + +// EmitInbox notifies every consumer of a new relay envelope stored for +// the given recipient. Summary is the minimal JSON the WS gateway ships +// to subscribers so the client can refresh on push instead of polling. +func (b *EventBus) EmitInbox(recipientX25519 string, summary json.RawMessage) { + for _, c := range b.consumers { + c.OnInbox(recipientX25519, summary) + } +} + +// EmitBlockWithTxs is the common path invoked on commit: one block + +// every tx in it, so each consumer can index/fan out appropriately. +func (b *EventBus) EmitBlockWithTxs(blk *blockchain.Block) { + b.EmitBlock(blk) + for _, tx := range blk.Transactions { + b.EmitTx(tx) + } +} + +// ─── Adapter: wrap the existing SSEHub in an EventConsumer ────────────────── + +type sseEventAdapter struct{ h *SSEHub } + +func (a sseEventAdapter) OnBlock(b *blockchain.Block) { a.h.EmitBlock(b) } +func (a sseEventAdapter) OnTx(tx *blockchain.Transaction) { a.h.EmitTx(tx) } +func (a sseEventAdapter) OnContractLog(e blockchain.ContractLogEntry) { a.h.EmitContractLog(e) } +// SSE has no inbox topic today — the existing hub doesn't expose one. The +// adapter silently drops it; when we add an inbox SSE event, this is the +// one place that needs an update. +func (a sseEventAdapter) OnInbox(string, json.RawMessage) {} + +// WrapSSE converts an SSEHub into an EventConsumer for the bus. +func WrapSSE(h *SSEHub) EventConsumer { return sseEventAdapter{h} } + +// ─── Adapter: wrap the WSHub ───────────────────────────────────────────────── + +type wsEventAdapter struct{ h *WSHub } + +func (a wsEventAdapter) OnBlock(b *blockchain.Block) { a.h.EmitBlock(b) } +func (a wsEventAdapter) OnTx(tx *blockchain.Transaction) { a.h.EmitTx(tx) } +func (a wsEventAdapter) OnContractLog(e blockchain.ContractLogEntry) { a.h.EmitContractLog(e) } +func (a wsEventAdapter) OnInbox(to string, sum json.RawMessage) { a.h.EmitInbox(to, sum) } + +// WrapWS converts a WSHub into an EventConsumer for the bus. +func WrapWS(h *WSHub) EventConsumer { return wsEventAdapter{h} } diff --git a/node/explorer/address.html b/node/explorer/address.html new file mode 100644 index 0000000..fa05b09 --- /dev/null +++ b/node/explorer/address.html @@ -0,0 +1,224 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Wallet | DChain Explorer + + + + + + + + + + + +
+ +
+ + + + + + + +
+ + + + + +
+
+ + +
+
+
+
Unknown Wallet
+
+
+
+ + +
+
Balance
+
+
+
+ +
+ + +
+
+ +
+
Address
+
+ + +
+
+ +
+
Public Key
+
+ + +
+
+ + + + + + + +
+
+ + + + + + + + + + + +
+
+

History

+ +
+
+ + + + + + + + + + + + + +
TimeTypeFrom → ToMemo / BlockAmount
No transactions yet.
+
+
+ +
+
+ +
+ +
+ + + diff --git a/node/explorer/address.js b/node/explorer/address.js new file mode 100644 index 0000000..f558900 --- /dev/null +++ b/node/explorer/address.js @@ -0,0 +1,414 @@ +(function() { + var C = window.ExplorerCommon; + + var state = { + currentAddress: '', + currentPubKey: '', + nextOffset: 0, + hasMore: false, + limit: 50 + }; + + /* ── Helpers ─────────────────────────────────────────────────────────────── */ + + function linkAddress(value, label) { + return '' + + C.esc(label || C.shortAddress(value)) + ''; + } + + function direction(tx, pubKey) { + if (tx.type === 'BLOCK_REWARD' || tx.type === 'HEARTBEAT') return { cls: 'recv', arrow: '↓' }; + if (tx.type === 'RELAY_PROOF') return { cls: 'recv', arrow: '↓' }; + if (tx.from === pubKey && (!tx.to || tx.to !== pubKey)) return { cls: 'sent', arrow: '↑' }; + if (tx.to === pubKey && tx.from !== pubKey) return { cls: 'recv', arrow: '↓' }; + return { cls: 'neutral', arrow: '·' }; + } + + /* ── Badge builder ───────────────────────────────────────────────────────── */ + + function badge(text, variant) { + return '' + C.esc(text) + ''; + } + + /* ── Profile render ──────────────────────────────────────────────────────── */ + + function renderProfile(addrData, identData, nodeData) { + var pubKey = addrData.pub_key || ''; + var address = addrData.address || ''; + + state.currentPubKey = pubKey; + + // Avatar initials (first letter of nickname or first char of address) + var name = (identData && identData.nickname) || ''; + var avatarChar = name ? name[0].toUpperCase() : (address ? address[2] || '◆' : '◆'); + document.getElementById('addrAvatar').textContent = avatarChar; + + // Name + document.getElementById('addrNickname').textContent = name || 'Unknown Wallet'; + + // Badges + var badges = ''; + if (identData && identData.registered) badges += badge('Registered', 'ok'); + if (nodeData && nodeData.blocks_produced > 0) badges += badge('Validator', 'accent'); + if (nodeData && nodeData.relay_proofs > 0) badges += badge('Relay Node', 'relay'); + document.getElementById('addrBadges').innerHTML = badges || badge('Unregistered', 'muted'); + + // Balance + document.getElementById('walletBalance').textContent = addrData.balance || C.toToken(addrData.balance_ut); + document.getElementById('walletBalanceSub').textContent = addrData.balance_ut != null + ? addrData.balance_ut + ' µT' : ''; + + // Address field + document.getElementById('walletAddress').textContent = address; + document.getElementById('walletAddress').title = address; + + // Public key field + document.getElementById('walletPubKey').textContent = pubKey; + document.getElementById('walletPubKey').title = pubKey; + + // X25519 key + if (identData && identData.x25519_pub) { + document.getElementById('walletX25519').textContent = identData.x25519_pub; + document.getElementById('walletX25519').title = identData.x25519_pub; + document.getElementById('x25519Row').style.display = ''; + } else { + document.getElementById('x25519Row').style.display = 'none'; + } + + // Node page link (if address is a node itself) + if (nodeData && (nodeData.blocks_produced > 0 || nodeData.relay_proofs > 0 || nodeData.heartbeats > 0)) { + var nodeLink = document.getElementById('nodePageLink'); + nodeLink.href = '/node?node=' + encodeURIComponent(pubKey); + document.getElementById('nodeLinkRow').style.display = ''; + } else { + document.getElementById('nodeLinkRow').style.display = 'none'; + } + + // Bound wallet (only for pure node addresses) + if (nodeData && nodeData.wallet_binding_address && nodeData.wallet_binding_address !== address) { + var wbl = document.getElementById('walletBindingLink'); + wbl.href = '/address?address=' + encodeURIComponent(nodeData.wallet_binding_address); + wbl.textContent = nodeData.wallet_binding_address; + document.getElementById('walletBindingRow').style.display = ''; + } else { + document.getElementById('walletBindingRow').style.display = 'none'; + } + + // Node stats panel + if (nodeData && (nodeData.blocks_produced > 0 || nodeData.relay_proofs > 0 || nodeData.heartbeats > 0)) { + document.getElementById('nodeBlocks').textContent = nodeData.blocks_produced || 0; + document.getElementById('nodeRelayProofs').textContent = nodeData.relay_proofs || 0; + document.getElementById('nodeRepScore').textContent = nodeData.reputation_score || 0; + document.getElementById('nodeRepRank').textContent = nodeData.reputation_rank || '—'; + document.getElementById('nodeHeartbeats').textContent = nodeData.heartbeats || 0; + document.getElementById('nodeSlashes').textContent = nodeData.slash_count || 0; + document.getElementById('nodeRecentRewards').textContent = nodeData.recent_rewards || '—'; + var window_ = nodeData.recent_window_blocks || 0; + var produced = nodeData.recent_blocks_produced || 0; + document.getElementById('nodeRecentWindow').textContent = + 'last ' + window_ + ' blocks, produced ' + produced; + document.getElementById('nodeStatsPanel').style.display = ''; + } else { + document.getElementById('nodeStatsPanel').style.display = 'none'; + } + } + + /* ── Transaction row builder ─────────────────────────────────────────────── */ + + function txTypeIcon(type) { + var icons = { + TRANSFER: 'arrow-left-right', + REGISTER_KEY: 'user-check', + RELAY_PROOF: 'zap', + HEARTBEAT: 'activity', + BLOCK_REWARD: 'layers-3', + BIND_WALLET: 'link', + REGISTER_RELAY: 'radio', + SLASH: 'alert-triangle', + ADD_VALIDATOR: 'shield-plus', + REMOVE_VALIDATOR: 'shield-minus', + }; + return icons[type] || 'circle'; + } + + function appendTxRows(txs) { + var body = document.getElementById('walletTxBody'); + for (var i = 0; i < txs.length; i++) { + var tx = txs[i]; + var dir = direction(tx, state.currentPubKey); + + var fromAddr = tx.from_addr || (tx.from ? C.shortAddress(tx.from) : '—'); + var toAddr = tx.to_addr || (tx.to ? C.shortAddress(tx.to) : '—'); + var fromCell = tx.from ? linkAddress(tx.from, tx.from_addr || C.shortAddress(tx.from)) : ''; + var toCell = tx.to ? linkAddress(tx.to, tx.to_addr || C.shortAddress(tx.to)) : ''; + + var amount = Number(tx.amount_ut || 0); + var amountStr = amount > 0 ? C.toToken(amount) : '—'; + var amountCls = dir.cls === 'recv' ? 'pos' : (dir.cls === 'sent' ? 'neg' : 'neutral'); + var amountSign = dir.cls === 'recv' ? '+' : (dir.cls === 'sent' ? '−' : ''); + + var memo = tx.memo ? C.esc(tx.memo) : ''; + var blockRef = tx.block_index != null + ? 'block #' + tx.block_index + '' + : ''; + + var tr = document.createElement('tr'); + tr.className = 'tx-row'; + tr.dataset.txid = tx.id || ''; + tr.innerHTML = + '' + + '
' + C.esc(C.timeAgo(tx.time)) + '
' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
' + C.esc(C.txLabel(tx.type)) + '
' + + '
' + C.esc(tx.type || '') + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '' + fromCell + '' + + '' + + '' + toCell + '' + + '
' + + '' + + '' + + (memo ? '
' + memo + '
' : '') + + blockRef + + '' + + '' + + '
' + amountSign + C.esc(amountStr) + '
' + + (tx.fee_ut ? '
fee ' + C.esc(C.toToken(tx.fee_ut)) + '
' : '') + + ''; + body.appendChild(tr); + } + C.refreshIcons(); + } + + /* ── Load wallet ─────────────────────────────────────────────────────────── */ + + function showEmpty() { + var es = document.getElementById('emptyState'); + var mc = document.getElementById('mainContent'); + if (es) es.style.display = ''; + if (mc) mc.style.display = 'none'; + } + + function showError(msg) { + var es = document.getElementById('emptyState'); + var mc = document.getElementById('mainContent'); + var eb = document.getElementById('errorBanner'); + var em = document.getElementById('errorMsg'); + var ps = document.getElementById('profileSection'); + if (es) es.style.display = 'none'; + if (eb) { eb.style.display = ''; if (em) em.textContent = msg; } + if (ps) ps.style.display = 'none'; + if (mc) mc.style.display = ''; + C.refreshIcons(); + } + + async function loadWallet(address) { + if (!address) return; + state.currentAddress = address; + state.nextOffset = 0; + state.hasMore = false; + + // Hide empty state, hide main, show loading + var es = document.getElementById('emptyState'); + if (es) es.style.display = 'none'; + document.getElementById('mainContent').style.display = 'none'; + document.getElementById('errorBanner').style.display = 'none'; + document.getElementById('profileSection').style.display = ''; + document.getElementById('walletTxBody').innerHTML = ''; + document.getElementById('walletTxCount').textContent = ''; + C.setStatus('Loading…', 'warn'); + + try { + // Parallel fetch: address data + identity + node info + var results = await Promise.all([ + C.fetchJSON('/api/address/' + encodeURIComponent(address) + + '?limit=' + state.limit + '&offset=0'), + C.fetchJSON('/api/identity/' + encodeURIComponent(address)).catch(function() { return null; }), + C.fetchJSON('/api/node/' + encodeURIComponent(address)).catch(function() { return null; }), + C.fetchJSON('/api/tokens').catch(function() { return null; }), + C.fetchJSON('/api/nfts/owner/' + encodeURIComponent(address)).catch(function() { return null; }), + ]); + var addrData = results[0]; + var identData = results[1]; + var nodeData = results[2]; + var tokensAll = results[3]; + var nftsOwned = results[4]; + + renderProfile(addrData, identData, nodeData); + + // ── Token balances ────────────────────────────────────────────────── + var pubKey = addrData.pub_key || address; + var allTokens = (tokensAll && Array.isArray(tokensAll.tokens)) ? tokensAll.tokens : []; + if (allTokens.length) { + var balFetches = allTokens.map(function(t) { + return C.fetchJSON('/api/tokens/' + t.token_id + '/balance/' + encodeURIComponent(pubKey)) + .then(function(b) { return { token: t, balance: b && b.balance != null ? b.balance : 0 }; }) + .catch(function() { return { token: t, balance: 0 }; }); + }); + var balResults = await Promise.all(balFetches); + var nonZero = balResults.filter(function(r) { return r.balance > 0; }); + if (nonZero.length) { + var rows = ''; + nonZero.forEach(function(r) { + var t = r.token; + var d = t.decimals || 0; + var sup = r.balance; + var whole = d > 0 ? Math.floor(sup / Math.pow(10, d)) : sup; + var frac = d > 0 ? sup % Math.pow(10, d) : 0; + var balFmt = whole.toLocaleString() + + (frac > 0 ? '.' + String(frac).padStart(d, '0').replace(/0+$/, '') : ''); + rows += '' + + '' + C.esc(t.symbol) + '' + + '' + C.esc(t.name) + '' + + '' + C.esc(balFmt) + '' + + 'Details' + + ''; + }); + document.getElementById('tokenBalBody').innerHTML = rows; + document.getElementById('tokenBalPanel').style.display = ''; + } + } + + // ── NFTs owned ────────────────────────────────────────────────────── + var ownedNFTs = (nftsOwned && Array.isArray(nftsOwned.nfts)) ? nftsOwned.nfts : []; + if (ownedNFTs.length) { + document.getElementById('nftOwnedCount').textContent = ownedNFTs.length + ' NFT' + (ownedNFTs.length !== 1 ? 's' : ''); + var cards = ''; + ownedNFTs.forEach(function(n) { + var imgHtml = (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri)) + ? '' + : '
'; + cards += '' + + imgHtml + + '
' + + '
' + C.esc(n.name) + '
' + + '
' + C.short(n.nft_id, 16) + '
' + + '
' + + '
'; + }); + document.getElementById('addrNFTGrid').innerHTML = cards; + document.getElementById('nftPanel').style.display = ''; + } + + var txs = addrData.transactions || []; + if (!txs.length) { + document.getElementById('walletTxBody').innerHTML = + 'No transactions for this wallet.'; + } else { + appendTxRows(txs); + } + + state.nextOffset = addrData.next_offset || txs.length; + state.hasMore = !!addrData.has_more; + setLoadMoreVisible(state.hasMore); + + var countText = txs.length + (state.hasMore ? '+' : '') + ' transaction' + + (txs.length !== 1 ? 's' : ''); + document.getElementById('walletTxCount').textContent = countText; + + // Update page title and URL + var displayName = (identData && identData.nickname) || addrData.address || address; + document.title = displayName + ' | DChain Explorer'; + window.history.replaceState({}, '', + '/address?address=' + encodeURIComponent(addrData.pub_key || address)); + + document.getElementById('mainContent').style.display = ''; + C.setStatus('', ''); + C.refreshIcons(); + + } catch (e) { + showError(e.message || 'Unknown error'); + C.setStatus('', ''); + } + } + + async function loadMore() { + if (!state.currentAddress || !state.hasMore) return; + C.setStatus('Loading more…', 'warn'); + setLoadMoreVisible(false); + try { + var data = await C.fetchJSON('/api/address/' + encodeURIComponent(state.currentAddress) + + '?limit=' + state.limit + '&offset=' + state.nextOffset); + var txs = data.transactions || []; + appendTxRows(txs); + state.nextOffset = data.next_offset || (state.nextOffset + txs.length); + state.hasMore = !!data.has_more; + + var prev = parseInt((document.getElementById('walletTxCount').textContent || '0'), 10) || 0; + var total = prev + txs.length; + document.getElementById('walletTxCount').textContent = + total + (state.hasMore ? '+' : '') + ' transactions'; + setLoadMoreVisible(state.hasMore); + C.setStatus('', ''); + } catch (e) { + C.setStatus('Load failed: ' + e.message, 'err'); + setLoadMoreVisible(true); + } + } + + function setLoadMoreVisible(v) { + var btn = document.getElementById('loadMoreBtn'); + if (btn) btn.style.display = v ? '' : 'none'; + } + + /* ── Copy buttons ────────────────────────────────────────────────────────── */ + + document.addEventListener('click', function(e) { + var t = e.target; + if (!t) return; + + // Copy button + var btn = t.closest ? t.closest('.copy-btn') : null; + if (btn) { + var src = document.getElementById(btn.dataset.copyId); + if (src) { + navigator.clipboard.writeText(src.textContent || src.title || '').catch(function() {}); + btn.classList.add('copy-btn-done'); + setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200); + } + return; + } + + // TX row click → tx detail page + var link = t.closest ? t.closest('a') : null; + if (link) return; + var row = t.closest ? t.closest('tr.tx-row') : null; + if (row && row.dataset && row.dataset.txid) { + window.location.href = '/tx?id=' + encodeURIComponent(row.dataset.txid); + } + }); + + /* ── Event wiring ────────────────────────────────────────────────────────── */ + + document.getElementById('addressBtn').addEventListener('click', function() { + var val = (document.getElementById('addressInput').value || '').trim(); + if (val) loadWallet(val); + }); + + document.getElementById('addressInput').addEventListener('keydown', function(e) { + if (e.key === 'Enter') document.getElementById('addressBtn').click(); + }); + + document.getElementById('loadMoreBtn').addEventListener('click', loadMore); + + /* ── Auto-load from URL param ────────────────────────────────────────────── */ + + var initial = C.q('address'); + if (initial) { + document.getElementById('addressInput').value = initial; + loadWallet(initial); + } else { + showEmpty(); + } + +})(); diff --git a/node/explorer/app.js b/node/explorer/app.js new file mode 100644 index 0000000..1d0ab20 --- /dev/null +++ b/node/explorer/app.js @@ -0,0 +1,484 @@ +(function() { + var C = window.ExplorerCommon; + + /* ── State ──────────────────────────────────────────────────────────────── */ + var state = { + txSeries: [], // tx counts per recent block (oldest → newest) + intervalSeries: [], // block intervals in seconds + supplyHistory: [], // cumulative tx count (proxy sparkline for supply) + tpsSeries: [], // same as txSeries, used for strip micro-bar + }; + + /* ── Address / Block links ──────────────────────────────────────────────── */ + function linkAddress(value, label) { + return '' + + C.esc(label || value) + ''; + } + + function linkBlock(index) { + return '#' + + C.esc(index) + ''; + } + + /* ── Micro chart helpers (strip sparklines) ─────────────────────────────── */ + + function resizeMicro(canvas) { + var dpr = window.devicePixelRatio || 1; + var w = canvas.offsetWidth || 80; + var h = canvas.offsetHeight || 36; + canvas.width = Math.max(1, Math.floor(w * dpr)); + canvas.height = Math.max(1, Math.floor(h * dpr)); + var ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { ctx: ctx, w: w, h: h }; + } + + /** Tiny bar chart for the TPS strip cell */ + function drawMicroBars(canvas, values, color) { + if (!canvas) return; + var r = resizeMicro(canvas); + var ctx = r.ctx; var w = r.w; var h = r.h; + ctx.clearRect(0, 0, w, h); + if (!values || !values.length) return; + var n = Math.min(values.length, 24); + var vs = values.slice(-n); + var max = Math.max.apply(null, vs.concat([1])); + var gap = 2; + var bw = Math.max(2, (w - gap * (n + 1)) / n); + ctx.fillStyle = color; + for (var i = 0; i < vs.length; i++) { + var bh = Math.max(2, (vs[i] / max) * (h - 4)); + var x = gap + i * (bw + gap); + var y = h - bh; + ctx.fillRect(x, y, bw, bh); + } + } + + /** Tiny sparkline with gradient fill for the Supply strip cell */ + function drawSparkline(canvas, values, color) { + if (!canvas) return; + var r = resizeMicro(canvas); + var ctx = r.ctx; var w = r.w; var h = r.h; + ctx.clearRect(0, 0, w, h); + if (!values || values.length < 2) { + // draw a flat line when no data + ctx.strokeStyle = color + '55'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(2, h / 2); + ctx.lineTo(w - 2, h / 2); + ctx.stroke(); + return; + } + var n = Math.min(values.length, 24); + var vs = values.slice(-n); + var mn = Math.min.apply(null, vs); + var mx = Math.max.apply(null, vs); + if (mn === mx) { mn = mn - 1; mx = mx + 1; } + var span = mx - mn; + var pad = 3; + + var pts = []; + for (var i = 0; i < vs.length; i++) { + var x = pad + (i / (vs.length - 1)) * (w - pad * 2); + var y = (h - pad) - ((vs[i] - mn) / span) * (h - pad * 2); + pts.push([x, y]); + } + + // gradient fill + var grad = ctx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, color + '40'); + grad.addColorStop(1, color + '05'); + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (var j = 1; j < pts.length; j++) ctx.lineTo(pts[j][0], pts[j][1]); + ctx.lineTo(pts[pts.length - 1][0], h); + ctx.lineTo(pts[0][0], h); + ctx.closePath(); + ctx.fillStyle = grad; + ctx.fill(); + + // line + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (var k = 1; k < pts.length; k++) ctx.lineTo(pts[k][0], pts[k][1]); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + /* ── Full-size chart helpers (main chart panels) ────────────────────────── */ + + function resizeCanvas(canvas) { + var dpr = window.devicePixelRatio || 1; + var w = canvas.clientWidth; + var h = canvas.clientHeight; + canvas.width = Math.max(1, Math.floor(w * dpr)); + canvas.height = Math.max(1, Math.floor(h * dpr)); + var ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return ctx; + } + + function chartLayout(width, height) { + return { + left: 44, + right: 10, + top: 10, + bottom: 26, + plotW: Math.max(10, width - 54), + plotH: Math.max(10, height - 36) + }; + } + + function fmtTick(v) { + if (Math.abs(v) >= 1000) return String(Math.round(v)); + if (Math.abs(v) >= 10) return (Math.round(v * 10) / 10).toFixed(1); + return (Math.round(v * 100) / 100).toFixed(2); + } + + function drawAxes(ctx, w, h, b, minY, maxY) { + ctx.strokeStyle = '#334155'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(b.left, b.top); ctx.lineTo(b.left, h - b.bottom); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(b.left, h - b.bottom); ctx.lineTo(w - b.right, h - b.bottom); ctx.stroke(); + ctx.fillStyle = '#94a3b8'; + ctx.font = '11px "Inter", sans-serif'; + for (var i = 0; i <= 4; i++) { + var ratio = i / 4; + var y = b.top + b.plotH * ratio; + var val = maxY - (maxY - minY) * ratio; + ctx.beginPath(); ctx.moveTo(b.left - 4, y); ctx.lineTo(b.left, y); ctx.stroke(); + ctx.fillText(fmtTick(val), 4, y + 3); + } + ctx.fillText('oldest', b.left, h - 8); + ctx.fillText('newest', w - b.right - 36, h - 8); + } + + function drawBars(canvas, values, color) { + if (!canvas) return; + var ctx = resizeCanvas(canvas); + var w = canvas.clientWidth; var h = canvas.clientHeight; + ctx.clearRect(0, 0, w, h); + var b = chartLayout(w, h); + var max = Math.max.apply(null, (values || []).concat([1])); + drawAxes(ctx, w, h, b, 0, max); + if (!values || !values.length) return; + var gap = 5; + var bw = Math.max(2, (b.plotW - gap * (values.length + 1)) / values.length); + ctx.fillStyle = color; + for (var i = 0; i < values.length; i++) { + var bh = max > 0 ? (values[i] / max) * b.plotH : 2; + var x = b.left + gap + i * (bw + gap); + var y = b.top + b.plotH - bh; + ctx.fillRect(x, y, bw, bh); + } + } + + function drawLine(canvas, values, color) { + if (!canvas) return; + var ctx = resizeCanvas(canvas); + var w = canvas.clientWidth; var h = canvas.clientHeight; + ctx.clearRect(0, 0, w, h); + var b = chartLayout(w, h); + if (!values || !values.length) { drawAxes(ctx, w, h, b, 0, 1); return; } + var mn = Math.min.apply(null, values); + var mx = Math.max.apply(null, values); + if (mn === mx) { mn = mn - 1; mx = mx + 1; } + drawAxes(ctx, w, h, b, mn, mx); + var span = mx - mn; + ctx.beginPath(); + for (var i = 0; i < values.length; i++) { + var x = values.length === 1 ? b.left + b.plotW / 2 + : b.left + (i * b.plotW) / (values.length - 1); + var ratio = span === 0 ? 0.5 : (values[i] - mn) / span; + var y = b.top + b.plotH - ratio * b.plotH; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + } + + function renderCharts() { + drawBars(document.getElementById('txChart'), state.txSeries, '#2563eb'); + drawLine(document.getElementById('intervalChart'), state.intervalSeries, '#0f766e'); + } + + function renderStripCharts() { + drawSparkline(document.getElementById('supplyChart'), state.supplyHistory, '#7db5ff'); + drawMicroBars(document.getElementById('tpsChart'), state.tpsSeries, '#41c98a'); + } + + /* ── renderStats ────────────────────────────────────────────────────────── */ + + function renderStats(net, blocks) { + // Supply strip + document.getElementById('sSupply').textContent = C.toTokenShort(net.total_supply); + + // Height + last block time + document.getElementById('sHeight').textContent = Number(net.total_blocks || 0); + if (blocks.length) { + document.getElementById('sLastTime').textContent = C.timeAgo(blocks[0].time); + } else { + document.getElementById('sLastTime').textContent = '—'; + } + + if (!blocks.length) { + document.getElementById('sTps').textContent = '—'; + document.getElementById('sBlockTime').textContent = '—'; + document.getElementById('sLastBlock').textContent = '—'; + document.getElementById('sLastHash').textContent = '—'; + document.getElementById('sTxs').textContent = net.total_txs || 0; + document.getElementById('sTransfers').textContent = 'Transfers: ' + (net.total_transfers || 0); + document.getElementById('sRelayProofs').textContent = net.total_relay_proofs || 0; + document.getElementById('sRelayCount').textContent = (net.relay_count || 0) + ' relay nodes'; + document.getElementById('sValidators').textContent = net.validator_count || 0; + document.getElementById('sTpsWindow').textContent = 'no data yet'; + state.txSeries = []; state.intervalSeries = []; state.supplyHistory = []; state.tpsSeries = []; + renderCharts(); + renderStripCharts(); + return; + } + + // Newest block + var newest = blocks[0]; + document.getElementById('sLastBlock').textContent = '#' + newest.index; + document.getElementById('sLastHash').textContent = C.short(newest.hash, 24); + + // TPS over window + var totalTx = 0; + for (var i = 0; i < blocks.length; i++) totalTx += Number(blocks[i].tx_count || 0); + var t0 = new Date(blocks[blocks.length - 1].time).getTime(); + var t1 = new Date(blocks[0].time).getTime(); + var secs = Math.max(1, (t1 - t0) / 1000); + var tps = (totalTx / secs).toFixed(2); + document.getElementById('sTps').textContent = tps; + document.getElementById('sTpsWindow').textContent = 'window: ~' + Math.round(secs) + ' sec'; + + // Avg block interval + var asc = blocks.slice().reverse(); + var intervals = []; + for (var j = 1; j < asc.length; j++) { + var prev = new Date(asc[j - 1].time).getTime(); + var cur = new Date(asc[j].time).getTime(); + intervals.push(Math.max(0, (cur - prev) / 1000)); + } + var avg = 0; + if (intervals.length) { + var sum = 0; + for (var k = 0; k < intervals.length; k++) sum += intervals[k]; + avg = sum / intervals.length; + } + document.getElementById('sBlockTime').textContent = avg ? avg.toFixed(2) + 's' : '—'; + + // Card stats + document.getElementById('sTxs').textContent = net.total_txs || 0; + document.getElementById('sTransfers').textContent = 'Transfers: ' + (net.total_transfers || 0); + document.getElementById('sRelayProofs').textContent = net.total_relay_proofs || 0; + document.getElementById('sRelayCount').textContent = (net.relay_count || 0) + ' relay nodes'; + document.getElementById('sValidators').textContent = net.validator_count || 0; + + // Series for charts + state.txSeries = asc.map(function(b) { return Number(b.tx_count || 0); }); + state.intervalSeries = intervals; + + // Cumulative tx sparkline (supply proxy — shows chain activity growth) + var cum = 0; + state.supplyHistory = asc.map(function(b) { cum += Number(b.tx_count || 0); return cum; }); + state.tpsSeries = state.txSeries; + + renderCharts(); + renderStripCharts(); + } + + /* ── renderRecentBlocks ─────────────────────────────────────────────────── */ + + function renderRecentBlocks(blocks) { + var tbody = document.getElementById('blocksBody'); + if (!tbody) return; + if (!blocks || !blocks.length) { + tbody.innerHTML = 'No blocks yet.'; + return; + } + + var rows = ''; + var shown = blocks.slice(0, 20); + for (var i = 0; i < shown.length; i++) { + var b = shown[i]; + var val = b.validator || b.proposer || ''; + var valShort = val ? C.shortAddress(val) : '—'; + var valCell = val + ? '' + C.esc(valShort) + '' + : '—'; + var fees = b.total_fees_ut != null ? C.toToken(Number(b.total_fees_ut)) : '—'; + rows += + '' + + '' + linkBlock(b.index) + '' + + '' + C.esc(C.short(b.hash, 20)) + '' + + '' + valCell + '' + + '' + C.esc(b.tx_count || 0) + '' + + '' + C.esc(fees) + '' + + '' + C.esc(C.timeAgo(b.time)) + '' + + ''; + } + tbody.innerHTML = rows; + C.refreshIcons(); + } + + /* ── Block detail panel ─────────────────────────────────────────────────── */ + + function showBlockPanel(data) { + var panel = document.getElementById('blockDetailPanel'); + var raw = document.getElementById('blockRaw'); + if (!panel || !raw) return; + raw.textContent = JSON.stringify(data, null, 2); + panel.style.display = ''; + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + function hideBlockPanel() { + var panel = document.getElementById('blockDetailPanel'); + if (panel) panel.style.display = 'none'; + } + + async function loadBlock(index) { + C.setStatus('Loading block #' + index + '…', 'warn'); + try { + var b = await C.fetchJSON('/api/block/' + encodeURIComponent(index)); + showBlockPanel(b); + C.setStatus('Block #' + index + ' loaded.', 'ok'); + } catch (e) { + C.setStatus('Block load failed: ' + e.message, 'err'); + } + } + + /* ── Main refresh ───────────────────────────────────────────────────────── */ + + async function refreshDashboard() { + try { + var data = await Promise.all([ + C.fetchJSON('/api/netstats'), + C.fetchJSON('/api/blocks?limit=36'), + ]); + renderStats(data[0] || {}, data[1] || []); + renderRecentBlocks(data[1] || []); + C.setStatus('Updated at ' + new Date().toLocaleTimeString(), 'ok'); + C.refreshIcons(); + } catch (e) { + C.setStatus('Refresh failed: ' + e.message, 'err'); + } + } + + /* ── Search ─────────────────────────────────────────────────────────────── */ + + function handleSearch() { + var raw = (document.getElementById('searchInput').value || '').trim(); + if (!raw) return; + + // Pure integer → block lookup + if (/^\d+$/.test(raw)) { + loadBlock(raw); + return; + } + + // Hex tx id (32 bytes = 64 chars) → tx page + if (/^[0-9a-fA-F]{64}$/.test(raw)) { + C.navAddress(raw); + return; + } + + // Short hex (16 chars) or partial → try as tx id + if (/^[0-9a-fA-F]{16,63}$/.test(raw)) { + C.navTx(raw); + return; + } + + // DC address or any remaining string → address page + C.navAddress(raw); + } + + /* ── Event wiring ───────────────────────────────────────────────────────── */ + + var searchBtn = document.getElementById('searchBtn'); + if (searchBtn) searchBtn.addEventListener('click', handleSearch); + + var openNodeBtn = document.getElementById('openNodeBtn'); + if (openNodeBtn) { + openNodeBtn.addEventListener('click', function() { + var raw = (document.getElementById('searchInput').value || '').trim(); + if (raw && C.isPubKey(raw)) { + window.location.href = '/node?node=' + encodeURIComponent(raw); + } else { + window.location.href = '/validators'; + } + }); + } + + var searchInput = document.getElementById('searchInput'); + if (searchInput) { + searchInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') handleSearch(); + }); + } + + // Block link clicks inside the blocks table + document.addEventListener('click', function(e) { + var t = e.target; + if (!t) return; + var link = t.closest ? t.closest('a.block-link') : null; + if (link) { + e.preventDefault(); + loadBlock(link.dataset.index); + return; + } + }); + + // Redraw on resize (both full-size and strip charts) + window.addEventListener('resize', function() { + renderCharts(); + renderStripCharts(); + }); + + /* ── Boot ───────────────────────────────────────────────────────────────── */ + + // Polling fallback — active when SSE is unavailable or disconnected. + var pollTimer = null; + + function startPolling() { + if (pollTimer) return; + pollTimer = setInterval(refreshDashboard, 10000); + } + + function stopPolling() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + } + + var bootPromise = refreshDashboard(); + + // SSE live feed — stop polling while connected; resume on disconnect. + var sseConn = C.connectSSE({ + connected: function() { + stopPolling(); + C.setStatus('● LIVE', 'ok'); + }, + block: function(/*ev*/) { + // A new block was committed — refresh everything immediately. + refreshDashboard(); + }, + error: function() { + startPolling(); + C.setStatus('Offline — polling every 10s', 'warn'); + } + }); + + // If SSE is not supported by the browser, fall back to polling immediately. + if (!sseConn) startPolling(); + + // Handle /?block=N — navigated here from tx page block link + var blockParam = C.q('block'); + if (blockParam) { + bootPromise.then(function() { loadBlock(blockParam); }); + } + +})(); diff --git a/node/explorer/common.js b/node/explorer/common.js new file mode 100644 index 0000000..7aa894f --- /dev/null +++ b/node/explorer/common.js @@ -0,0 +1,197 @@ +(function() { + function esc(v) { + return String(v === undefined || v === null ? '' : v) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function short(v, n) { + if (!v) return '-'; + return v.length > n ? v.slice(0, n) + '...' : v; + } + + function fmtTime(iso) { + if (!iso) return '-'; + return iso.replace('T', ' ').replace('Z', ' UTC'); + } + + function timeAgo(iso) { + if (!iso) return '-'; + var now = Date.now(); + var ts = new Date(iso).getTime(); + if (!isFinite(ts)) return '-'; + var diffSec = Math.max(0, Math.floor((now - ts) / 1000)); + if (diffSec < 10) return 'a few seconds ago'; + if (diffSec < 60) return diffSec + ' seconds ago'; + var diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return diffMin === 1 ? 'a minute ago' : diffMin + ' minutes ago'; + var diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return diffHour === 1 ? 'an hour ago' : diffHour + ' hours ago'; + var diffDay = Math.floor(diffHour / 24); + if (diffDay === 1) return 'yesterday'; + if (diffDay < 30) return diffDay + ' days ago'; + var diffMonth = Math.floor(diffDay / 30); + if (diffMonth < 12) return diffMonth === 1 ? 'a month ago' : diffMonth + ' months ago'; + var diffYear = Math.floor(diffMonth / 12); + return diffYear === 1 ? 'a year ago' : diffYear + ' years ago'; + } + + function shortAddress(addr) { + if (!addr) return '-'; + if (addr.length <= 9) return addr; + return addr.slice(0, 3) + '...' + addr.slice(-3); + } + + function txLabel(eventType) { + var map = { + TRANSFER: 'Transfer', + REGISTER_KEY: 'Register', + CREATE_CHANNEL: 'Create Channel', + ADD_MEMBER: 'Add Member', + OPEN_PAY_CHAN: 'Open Channel', + CLOSE_PAY_CHAN: 'Close Channel', + RELAY_PROOF: 'Relay Proof', + BIND_WALLET: 'Bind Wallet', + SLASH: 'Slash', + HEARTBEAT: 'Heartbeat', + BLOCK_REWARD: 'Reward' + }; + return map[eventType] || eventType || 'Transaction'; + } + + function toToken(micro) { + if (micro === undefined || micro === null) return '-'; + var n = Number(micro); + if (isNaN(n)) return String(micro); + return (n / 1000000).toFixed(6) + ' T'; + } + + // Compact token display: 21000000 T → "21M T", 1234 T → "1.23k T" + function _fmtNum(n) { + if (n >= 100) return Math.round(n).toString(); + if (n >= 10) return n.toFixed(1).replace(/\.0$/, ''); + return n.toFixed(2).replace(/\.?0+$/, ''); + } + function toTokenShort(micro) { + if (micro === undefined || micro === null) return '-'; + var t = Number(micro) / 1000000; + if (isNaN(t)) return String(micro); + if (t >= 1e9) return _fmtNum(t / 1e9) + 'B T'; + if (t >= 1e6) return _fmtNum(t / 1e6) + 'M T'; + if (t >= 1e3) return _fmtNum(t / 1e3) + 'k T'; + if (t >= 0.01) return _fmtNum(t) + ' T'; + var ut = Number(micro); + if (ut >= 1000) return _fmtNum(ut / 1000) + 'k µT'; + return ut + ' µT'; + } + + function isPubKey(s) { + return /^[0-9a-fA-F]{64}$/.test(s || ''); + } + + function setStatus(text, cls) { + var el = document.getElementById('status'); + if (!el) return; + el.className = 'status' + (cls ? ' ' + cls : ''); + el.textContent = text; + } + + async function fetchJSON(url) { + var resp = await fetch(url); + var json = await resp.json().catch(function() { return {}; }); + if (!resp.ok) { + throw new Error(json && json.error ? json.error : 'request failed'); + } + if (json && json.error) { + throw new Error(json.error); + } + return json; + } + + function q(name) { + return new URLSearchParams(window.location.search).get(name) || ''; + } + + function navAddress(address) { + window.location.href = '/address?address=' + encodeURIComponent(address); + } + + function navTx(id) { + window.location.href = '/tx?id=' + encodeURIComponent(id); + } + + function navNode(node) { + window.location.href = '/node?node=' + encodeURIComponent(node); + } + + function refreshIcons() { + if (window.lucide && typeof window.lucide.createIcons === 'function') { + window.lucide.createIcons(); + } + } + + // ── SSE live event stream ───────────────────────────────────────────────── + // + // connectSSE(handlers) opens a connection to GET /api/events and dispatches + // events to the supplied handler map, e.g.: + // + // C.connectSSE({ + // block: function(data) { ... }, + // tx: function(data) { ... }, + // contract_log: function(data) { ... }, + // connected: function() { /* SSE connection established */ }, + // error: function() { /* connection lost */ }, + // }); + // + // Returns the EventSource instance so the caller can close it if needed. + function connectSSE(handlers) { + if (!window.EventSource) return null; // browser doesn't support SSE + + var es = new EventSource('/api/events'); + + es.addEventListener('open', function() { + if (handlers.connected) handlers.connected(); + }); + + es.addEventListener('error', function() { + if (handlers.error) handlers.error(); + }); + + ['block', 'tx', 'contract_log'].forEach(function(type) { + if (!handlers[type]) return; + es.addEventListener(type, function(e) { + try { + var data = JSON.parse(e.data); + handlers[type](data); + } catch (_) {} + }); + }); + + return es; + } + + window.ExplorerCommon = { + esc: esc, + short: short, + fmtTime: fmtTime, + timeAgo: timeAgo, + shortAddress: shortAddress, + txLabel: txLabel, + toToken: toToken, + toTokenShort: toTokenShort, + isPubKey: isPubKey, + setStatus: setStatus, + fetchJSON: fetchJSON, + q: q, + navAddress: navAddress, + navTx: navTx, + navNode: navNode, + refreshIcons: refreshIcons, + connectSSE: connectSSE + }; + + refreshIcons(); +})(); diff --git a/node/explorer/contract.html b/node/explorer/contract.html new file mode 100644 index 0000000..de7271d --- /dev/null +++ b/node/explorer/contract.html @@ -0,0 +1,222 @@ + + + + + +Contract | DChain Explorer + + + + + + + + + + +
+ +
+ + + + + +
+
+
+ Deployed Contracts + +
+ + +
+ Loading… +
+
+
+ +
+ + +
+
+
+ +
+
+
Smart Contract
+
+
+
+
+
+ + +
+ + +
+ + + + +
+ + +
+
+ +
+
Contract ID
+
+ + +
+
+ +
+
Deployer
+
+ + + +
+
+ +
+
Deployed at block
+
+
+ +
+
WASM size
+
+
+ +
+ + + +
+ + + + + + + + + + +
+ +
+ + + diff --git a/node/explorer/contract.js b/node/explorer/contract.js new file mode 100644 index 0000000..1bfb848 --- /dev/null +++ b/node/explorer/contract.js @@ -0,0 +1,295 @@ +(function() { + var C = window.ExplorerCommon; + + var currentContractID = ''; + + /* ── Tab switching ───────────────────────────────────────────────────────── */ + + function switchTab(active) { + var tabs = ['Overview', 'State', 'Logs', 'Raw']; + tabs.forEach(function(name) { + var btn = document.getElementById('tab' + name); + var pane = document.getElementById('pane' + name); + if (!btn || !pane) return; + var isActive = (name === active); + btn.className = 'tx-tab' + (isActive ? ' tx-tab-active' : ''); + pane.style.display = isActive ? '' : 'none'; + }); + // Lazy-load logs when tab is opened + if (active === 'Logs' && currentContractID) { + loadLogs(currentContractID); + } + } + + document.getElementById('tabOverview').addEventListener('click', function() { switchTab('Overview'); }); + document.getElementById('tabState').addEventListener('click', function() { switchTab('State'); }); + document.getElementById('tabLogs').addEventListener('click', function() { switchTab('Logs'); }); + document.getElementById('tabRaw').addEventListener('click', function() { switchTab('Raw'); }); + + /* ── ABI rendering ───────────────────────────────────────────────────────── */ + + function renderABI(abiJson) { + var abi = null; + try { + abi = typeof abiJson === 'string' ? JSON.parse(abiJson) : abiJson; + } catch (e) { return; } + if (!abi || !Array.isArray(abi.methods) || abi.methods.length === 0) return; + + var tbody = document.getElementById('abiMethodsBody'); + tbody.innerHTML = ''; + abi.methods.forEach(function(m) { + var args = Array.isArray(m.args) && m.args.length > 0 + ? m.args.map(function(a) { return C.esc(a.name || '') + ': ' + C.esc(a.type || '?'); }).join(', ') + : 'none'; + var tr = document.createElement('tr'); + tr.innerHTML = + '' + C.esc(m.name || '?') + '' + + '' + args + ''; + tbody.appendChild(tr); + }); + document.getElementById('abiSection').style.display = ''; + } + + /* ── Main render ─────────────────────────────────────────────────────────── */ + + function renderContract(contract) { + document.title = 'Contract ' + C.short(contract.contract_id, 16) + ' | DChain Explorer'; + currentContractID = contract.contract_id; + + // Banner + document.getElementById('bannerContractId').textContent = contract.contract_id || '—'; + document.getElementById('bannerDeployedAt').textContent = + contract.deployed_at != null ? 'Block #' + contract.deployed_at : '—'; + + // Overview fields + document.getElementById('contractId').textContent = contract.contract_id || '—'; + + if (contract.deployer_pub) { + document.getElementById('contractDeployer').innerHTML = + '' + + C.esc(C.shortAddress(contract.deployer_pub)) + ''; + document.getElementById('contractDeployerRaw').textContent = contract.deployer_pub; + } else { + document.getElementById('contractDeployer').textContent = '—'; + } + + document.getElementById('contractDeployedBlock').textContent = + contract.deployed_at != null ? '#' + contract.deployed_at : '—'; + + document.getElementById('contractWasmSize').textContent = + contract.wasm_size != null ? contract.wasm_size.toLocaleString() + ' bytes' : '—'; + + // ABI + if (contract.abi_json) { + renderABI(contract.abi_json); + } + + // Raw JSON + document.getElementById('contractRaw').textContent = JSON.stringify(contract, null, 2); + + document.getElementById('mainContent').style.display = ''; + C.refreshIcons(); + } + + /* ── Contracts list ──────────────────────────────────────────────────────── */ + + async function loadContractsList() { + try { + var data = await C.fetchJSON('/api/contracts'); + var contracts = data.contracts || []; + var loading = document.getElementById('contractsLoading'); + var empty = document.getElementById('contractsEmpty'); + var wrap = document.getElementById('contractsTableWrap'); + var count = document.getElementById('contractsCount'); + if (loading) loading.style.display = 'none'; + count.textContent = contracts.length + ' contract' + (contracts.length !== 1 ? 's' : ''); + if (contracts.length === 0) { + empty.style.display = ''; + return; + } + var tbody = document.getElementById('contractsBody'); + tbody.innerHTML = ''; + contracts.forEach(function(c) { + var tr = document.createElement('tr'); + var idShort = C.short(c.contract_id, 20); + var deployer = c.deployer_pub ? C.shortAddress(c.deployer_pub) : '—'; + var deployerHref = c.deployer_pub + ? '' + C.esc(deployer) + '' + : ''; + tr.innerHTML = + '' + C.esc(idShort) + '' + + '' + deployerHref + '' + + '' + (c.deployed_at != null ? '#' + c.deployed_at : '—') + '' + + '' + (c.wasm_size != null ? c.wasm_size.toLocaleString() + ' B' : '—') + ''; + tbody.appendChild(tr); + }); + wrap.style.display = ''; + C.refreshIcons(); + } catch (e) { + var loading = document.getElementById('contractsLoading'); + if (loading) loading.textContent = 'Failed to load contracts: ' + e.message; + } + } + + /* ── Load contract ───────────────────────────────────────────────────────── */ + + async function loadContract(id) { + if (!id) return; + C.setStatus('Loading…', 'warn'); + document.getElementById('contractsList').style.display = 'none'; + document.getElementById('mainContent').style.display = 'none'; + document.getElementById('abiSection').style.display = 'none'; + switchTab('Overview'); + try { + var contract = await C.fetchJSON('/api/contracts/' + encodeURIComponent(id)); + renderContract(contract); + window.history.replaceState({}, '', '/contract?id=' + encodeURIComponent(id)); + C.setStatus('', ''); + } catch (e) { + C.setStatus('Contract not found: ' + e.message, 'err'); + document.getElementById('contractsList').style.display = ''; + } + } + + /* ── State browser ───────────────────────────────────────────────────────── */ + + async function queryState(key) { + if (!currentContractID || !key) return; + C.setStatus('Querying state…', 'warn'); + document.getElementById('stateResult').style.display = 'none'; + document.getElementById('stateEmpty').style.display = 'none'; + try { + var data = await C.fetchJSON( + '/api/contracts/' + encodeURIComponent(currentContractID) + + '/state/' + encodeURIComponent(key) + ); + C.setStatus('', ''); + + document.getElementById('stateKey').textContent = data.key || key; + + if (data.value_hex === null || data.value_hex === undefined) { + // Key not set + document.getElementById('stateU64Row').style.display = 'none'; + document.getElementById('stateHex').textContent = '—'; + document.getElementById('stateB64').textContent = '—'; + document.getElementById('stateNullRow').style.display = ''; + } else { + document.getElementById('stateNullRow').style.display = 'none'; + document.getElementById('stateHex').textContent = data.value_hex || '—'; + document.getElementById('stateB64').textContent = data.value_b64 || '—'; + if (data.value_u64 !== null && data.value_u64 !== undefined) { + document.getElementById('stateU64').textContent = data.value_u64.toLocaleString(); + document.getElementById('stateU64Row').style.display = ''; + } else { + document.getElementById('stateU64Row').style.display = 'none'; + } + } + + document.getElementById('stateResult').style.display = ''; + C.refreshIcons(); + } catch (e) { + C.setStatus('State query failed: ' + e.message, 'err'); + document.getElementById('stateEmpty').style.display = ''; + } + } + + document.getElementById('stateLoadBtn').addEventListener('click', function() { + var key = (document.getElementById('stateKeyInput').value || '').trim(); + if (key) queryState(key); + }); + + document.getElementById('stateKeyInput').addEventListener('keydown', function(e) { + if (e.key === 'Enter') document.getElementById('stateLoadBtn').click(); + }); + + /* ── Logs ────────────────────────────────────────────────────────────────── */ + + var logsLoaded = false; + + function makeLogRow(entry) { + var tr = document.createElement('tr'); + var txShort = entry.tx_id ? C.short(entry.tx_id, 12) : '—'; + var txLink = entry.tx_id + ? '' + C.esc(txShort) + '' + : ''; + tr.innerHTML = + '#' + entry.block_height + '' + + '' + txLink + '' + + '' + entry.seq + '' + + '' + C.esc(entry.message) + ''; + return tr; + } + + async function loadLogs(contractID) { + if (logsLoaded) return; + logsLoaded = true; + try { + var data = await C.fetchJSON('/api/contracts/' + encodeURIComponent(contractID) + '/logs?limit=100'); + var logs = data.logs || []; + if (logs.length === 0) { + document.getElementById('logsEmpty').style.display = ''; + document.getElementById('logsTableWrap').style.display = 'none'; + return; + } + var tbody = document.getElementById('logsBody'); + tbody.innerHTML = ''; + logs.forEach(function(entry) { tbody.appendChild(makeLogRow(entry)); }); + document.getElementById('logsEmpty').style.display = 'none'; + document.getElementById('logsTableWrap').style.display = ''; + } catch (e) { + document.getElementById('logsEmpty').textContent = 'Failed to load logs: ' + e.message; + document.getElementById('logsEmpty').style.display = ''; + } + } + + // SSE — prepend live contract_log entries for the currently viewed contract. + C.connectSSE({ + contract_log: function(entry) { + if (!currentContractID || entry.contract_id !== currentContractID) return; + var tbody = document.getElementById('logsBody'); + if (!tbody) return; + tbody.insertBefore(makeLogRow(entry), tbody.firstChild); + document.getElementById('logsEmpty').style.display = 'none'; + document.getElementById('logsTableWrap').style.display = ''; + } + }); + + /* ── Copy buttons ────────────────────────────────────────────────────────── */ + + document.addEventListener('click', function(e) { + var t = e.target; + if (!t) return; + var btn = t.closest ? t.closest('.copy-btn') : null; + if (btn) { + var src = document.getElementById(btn.dataset.copyId); + if (src) { + navigator.clipboard.writeText(src.textContent || '').catch(function() {}); + btn.classList.add('copy-btn-done'); + setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200); + } + } + }); + + /* ── Wiring ──────────────────────────────────────────────────────────────── */ + + document.getElementById('contractBtn').addEventListener('click', function() { + var val = (document.getElementById('contractInput').value || '').trim(); + if (val) { + logsLoaded = false; + loadContract(val); + } + }); + + document.getElementById('contractInput').addEventListener('keydown', function(e) { + if (e.key === 'Enter') document.getElementById('contractBtn').click(); + }); + + var initial = C.q('id'); + if (initial) { + document.getElementById('contractInput').value = initial; + loadContract(initial); + } else { + loadContractsList(); + } + +})(); diff --git a/node/explorer/index.html b/node/explorer/index.html new file mode 100644 index 0000000..ea2326e --- /dev/null +++ b/node/explorer/index.html @@ -0,0 +1,166 @@ + + + + + +DChain Explorer + + + + + + + + + + + +
+ +
+ + +
+
+

+ + DChain Explorer +

+

Ed25519 blockchain · PBFT consensus · NaCl E2E messaging

+ + + +
+ Validators + Relay Nodes + +
+ +
Loading…
+
+
+ + +
+
+ +
+
Total Supply
+
+ +
+ +
+ +
+
Chain Height
+
+
+
+ +
+ +
+
Avg Block Time
+
+
recent window
+
+ +
+ +
+
Current TPS
+
+ +
+ +
+
+ + +
+ + +
+
+
Last Block
+
+
+
+
+
Total Transactions
+
+
+
+
+
Relay Proofs
+
+
+
+
+
Validators
+
+
+
+
+ + +
+
+

Transactions per Block

+
+
+
+

Block Interval (sec)

+
+
+
+ + +
+

Recent Blocks

+
+ + + + + + + + + + + + + + +
BlockHashValidatorTxsFeesTime
Loading…
+
+
+ + + + +
+ + + diff --git a/node/explorer/node.html b/node/explorer/node.html new file mode 100644 index 0000000..3e5eb2a --- /dev/null +++ b/node/explorer/node.html @@ -0,0 +1,146 @@ + + + + + +Node | DChain Explorer + + + + + + + + + + +
+ +
+ + + + +
+ + +
+
+
+ +
+
+
Node
+
+
+
+
+
+
Node Balance
+
+
+
+
+ + +
+
+ +
+
Node Address
+
+ + + +
+
+ +
+
Public Key
+
+ + +
+
+ + + +
+
+ + +
+

Performance

+
+
+
Reputation Score
+
+
+
+
+
Blocks Produced
+
+
+
+
+
Relay Proofs
+
+
+
+
Heartbeats
+
+
+
+
+
+ + +
+

Rewards

+
+
+
Recent Rewards
+
+
+
+
+
Lifetime Base Reward
+
+
+
+
Node Balance
+
+
+
+
+ +
+ + + diff --git a/node/explorer/node.js b/node/explorer/node.js new file mode 100644 index 0000000..4d95a7d --- /dev/null +++ b/node/explorer/node.js @@ -0,0 +1,110 @@ +(function() { + var C = window.ExplorerCommon; + + function badge(text, variant) { + return '' + C.esc(text) + ''; + } + + function renderNode(data) { + document.title = 'Node ' + C.short(data.address || data.pub_key || '', 16) + ' | DChain Explorer'; + + // Profile card + document.getElementById('nodeNickname').textContent = data.address ? C.short(data.address, 24) : 'Node'; + document.getElementById('nodeAddressShort').textContent = data.pub_key ? C.short(data.pub_key, 32) : '—'; + document.getElementById('nodeBalanceVal').textContent = data.node_balance || C.toToken(data.node_balance_ut || 0); + document.getElementById('nodeReputationRank').textContent = 'Rank: ' + (data.reputation_rank || '—'); + + // Badges + var badges = ''; + if (data.blocks_produced > 0) badges += badge('Validator', 'accent'); + if (data.relay_proofs > 0) badges += badge('Relay Node', 'relay'); + if (!badges) badges = badge('Observer', 'muted'); + document.getElementById('nodeBadges').innerHTML = badges; + + // Address field + var addrEl = document.getElementById('nodeAddress'); + addrEl.textContent = data.address || '—'; + addrEl.href = data.pub_key ? '/address?address=' + encodeURIComponent(data.pub_key) : '#'; + document.getElementById('nodeAddressRaw').textContent = data.address || ''; + + // PubKey field + document.getElementById('nodePubKey').textContent = data.pub_key || '—'; + + // Wallet binding + if (data.wallet_binding_address) { + var link = document.getElementById('bindingLink'); + link.textContent = data.wallet_binding_address; + link.href = '/address?address=' + encodeURIComponent(data.wallet_binding_pub_key || data.wallet_binding_address); + document.getElementById('bindingBalance').textContent = data.wallet_binding_balance || '—'; + document.getElementById('bindingRow').style.display = ''; + } else { + document.getElementById('bindingRow').style.display = 'none'; + } + + // Stats + document.getElementById('repScore').textContent = String(data.reputation_score || 0); + document.getElementById('repRank').textContent = data.reputation_rank || '—'; + document.getElementById('repBlocks').textContent = String(data.blocks_produced || 0); + var rw = data.recent_window_blocks || 0; + var rp = data.recent_blocks_produced || 0; + document.getElementById('recentProduced').textContent = 'last ' + rw + ' blocks: ' + rp; + document.getElementById('repRelay').textContent = String(data.relay_proofs || 0); + document.getElementById('repHeartbeats').textContent = String(data.heartbeats || 0); + var slash = data.slash_count || 0; + document.getElementById('repSlash').textContent = slash > 0 ? slash + ' slashes' : ''; + + // Rewards + document.getElementById('recentRewards').textContent = data.recent_rewards || C.toToken(data.recent_rewards_ut || 0); + document.getElementById('windowBlocks').textContent = 'window: last ' + rw + ' blocks'; + document.getElementById('lifetimeReward').textContent = data.lifetime_base_reward || C.toToken(0); + document.getElementById('nodeBalance').textContent = data.node_balance || C.toToken(data.node_balance_ut || 0); + + document.getElementById('mainContent').style.display = ''; + C.refreshIcons(); + } + + async function loadNode(nodeID) { + if (!nodeID) return; + C.setStatus('Loading…', 'warn'); + document.getElementById('mainContent').style.display = 'none'; + try { + var data = await C.fetchJSON('/api/node/' + encodeURIComponent(nodeID) + '?window=300'); + renderNode(data); + window.history.replaceState({}, '', '/node?node=' + encodeURIComponent(nodeID)); + C.setStatus('', ''); + } catch (e) { + C.setStatus('Load failed: ' + e.message, 'err'); + } + } + + /* ── Copy buttons ────────────────────────────────────────────────────────── */ + document.addEventListener('click', function(e) { + var t = e.target; + if (!t) return; + var btn = t.closest ? t.closest('.copy-btn') : null; + if (btn) { + var src = document.getElementById(btn.dataset.copyId); + if (src) { + navigator.clipboard.writeText(src.textContent || '').catch(function() {}); + btn.classList.add('copy-btn-done'); + setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200); + } + } + }); + + /* ── Wiring ────────────────────────────────────────────────────────────── */ + document.getElementById('nodeBtn').addEventListener('click', function() { + var val = (document.getElementById('nodeInput').value || '').trim(); + if (val) loadNode(val); + }); + + document.getElementById('nodeInput').addEventListener('keydown', function(e) { + if (e.key === 'Enter') document.getElementById('nodeBtn').click(); + }); + + var initial = C.q('node') || C.q('pk'); + if (initial) { + document.getElementById('nodeInput').value = initial; + loadNode(initial); + } +})(); diff --git a/node/explorer/relays.html b/node/explorer/relays.html new file mode 100644 index 0000000..5f53dce --- /dev/null +++ b/node/explorer/relays.html @@ -0,0 +1,78 @@ + + + + + +Relay Nodes | DChain Explorer + + + + + + + + + + +
+ +
+ +
+ + +
+
+
+
+

Relay Nodes

+

+ Nodes registered on-chain as NaCl E2E relay service providers. +

+
+
+
+
+
+
Relays
+
+ +
+
+ +
+ +
+
+ + + + + + + + + + + + + + +
#Node AddressX25519 Relay KeyFee / msgMultiaddrActions
Loading…
+
+
+ +
+ + diff --git a/node/explorer/relays.js b/node/explorer/relays.js new file mode 100644 index 0000000..39dc451 --- /dev/null +++ b/node/explorer/relays.js @@ -0,0 +1,68 @@ +(function() { + var C = window.ExplorerCommon; + + function fmtFee(ut) { + if (!ut) return 'Free'; + if (ut < 1000) return ut + ' µT'; + return C.toToken(ut); + } + + async function loadRelays() { + C.setStatus('Loading…', 'warn'); + try { + var data = await C.fetchJSON('/api/relays'); + var relays = Array.isArray(data) ? data : []; + + document.getElementById('relayCount').textContent = relays.length; + + var tbody = document.getElementById('relayBody'); + if (!relays.length) { + tbody.innerHTML = 'No relay nodes registered yet.'; + C.setStatus('No relay nodes found.', 'warn'); + return; + } + + var rows = ''; + relays.forEach(function(info, i) { + var pubKey = info.pub_key || ''; + var addr = info.address || '—'; + var x25519 = (info.relay && info.relay.x25519_pub_key) || '—'; + var feeUT = (info.relay && info.relay.fee_per_msg_ut) || 0; + var multiaddr = (info.relay && info.relay.multiaddr) || ''; + + rows += + '' + + '' + (i + 1) + '' + + '' + + (pubKey + ? '' + C.esc(addr) + '' + : C.esc(addr)) + + '' + + '' + + C.esc(C.short(x25519, 28)) + + '' + + '' + fmtFee(feeUT) + '' + + '' + + (multiaddr ? C.esc(multiaddr) : '') + + '' + + '' + + (pubKey + ? '' + + ' Node' + + '' + : '') + + '' + + ''; + }); + tbody.innerHTML = rows; + + C.setStatus(relays.length + ' relay node' + (relays.length !== 1 ? 's' : '') + ' registered.', 'ok'); + C.refreshIcons(); + } catch (e) { + C.setStatus('Load failed: ' + e.message, 'err'); + } + } + + document.getElementById('refreshBtn').addEventListener('click', loadRelays); + loadRelays(); +})(); diff --git a/node/explorer/style.css b/node/explorer/style.css new file mode 100644 index 0000000..cd0af5b --- /dev/null +++ b/node/explorer/style.css @@ -0,0 +1,1704 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + DChain Explorer — global stylesheet + All pages share these base tokens + utilities. + Index-page (Tonviewer-style) styles are appended at the bottom. + ═══════════════════════════════════════════════════════════════════════════ */ + +:root { + --bg: #0b1220; + --surface: #111a2b; + --surface2: #0f1729; + --line: #1c2840; + --text: #e6edf9; + --muted: #98a7c2; + --accent: #7db5ff; + --ok: #41c98a; + --warn: #f0b35a; + --err: #ff7a87; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Inter", "Manrope", "Segoe UI", sans-serif; +} + +/* ─── Generic Layout ─────────────────────────────────────────────────────── */ + +.layout { + max-width: 1180px; + margin: 0 auto; + padding: 16px; +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + color: var(--muted); + font-size: 13px; +} + +.crumb { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + text-decoration: none; +} + +.crumb.active { color: var(--text); font-weight: 600; } +.crumb-sep { color: #9ca3af; } + +/* ─── Panels ─────────────────────────────────────────────────────────────── */ + +.panel { + background: var(--surface); + border-radius: 10px; + padding: 16px; + margin-bottom: 14px; +} + +.panel-head h1, +.panel h2 { + margin: 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; + font-weight: 600; +} + +.panel h2 { + font-size: 15px; + margin-bottom: 12px; +} + +.panel-head p { + margin: 6px 0 10px; + color: var(--muted); + font-size: 13px; +} + +/* ─── Search Line ────────────────────────────────────────────────────────── */ + +.search-line { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.search-line input { + flex: 1; + min-width: 260px; + border: none; + border-radius: 6px; + padding: 10px 12px; + font-size: 14px; + background: var(--surface2); + color: var(--text); +} + +.search-line input:focus { + outline: none; + box-shadow: inset 0 0 0 1px #2a436c; +} + +/* ─── Buttons ────────────────────────────────────────────────────────────── */ + +.btn { + border: none; + border-radius: 6px; + padding: 10px 12px; + background: #1a2a45; + color: var(--text); + font-size: 14px; + cursor: pointer; +} + +.btn:hover { background: #21375a; } +.btn-muted { color: var(--muted); } + +/* ─── Status badge ───────────────────────────────────────────────────────── */ + +.status { + margin-top: 8px; + font-size: 12px; + color: var(--muted); +} + +.status.ok { color: var(--ok); } +.status.warn { color: var(--warn); } +.status.err { color: var(--err); } + +/* ─── Stats Grid (other pages) ───────────────────────────────────────────── */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.stat { + background: var(--surface); + border-radius: 8px; + padding: 10px; +} + +.stat-key { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.stat-val { + font-size: 18px; + font-weight: 700; + line-height: 1.25; +} + +.stat-sub { + margin-top: 4px; + color: var(--muted); + font-size: 11px; +} + +.split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; +} + +.chart-wrap { + height: 170px; + border-radius: 6px; + background: var(--surface2); + padding: 8px; +} + +canvas { width: 100%; height: 100%; display: block; } + +/* ─── KV Grid ────────────────────────────────────────────────────────────── */ + +.kv-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.kv-item { + border-radius: 6px; + padding: 8px; + min-height: 62px; + background: var(--surface2); +} + +.kv-key { + font-size: 12px; + color: var(--muted); + margin-bottom: 4px; +} + +.kv-val { + font-size: 14px; + font-weight: 500; + word-break: break-all; +} + +/* ─── Tables ─────────────────────────────────────────────────────────────── */ + +.table-wrap { overflow-x: auto; } + +table { width: 100%; border-collapse: collapse; font-size: 13px; } + +th, td { + padding: 9px 10px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +} + +th { color: var(--muted); font-size: 12px; font-weight: 600; } + +tbody tr:hover td { background: #0f1b31; } + +.tbl-empty { + color: var(--muted); + text-align: center; + padding: 20px; +} + +/* ─── TX list (address / tx pages) ──────────────────────────────────────── */ + +.tx-list { display: flex; flex-direction: column; } + +.tx-list .tx-row { + padding: 10px 8px; + border-bottom: 1px solid var(--line); +} + +.tx-list .tx-row:hover { background: #0f1b31; } + +.tx-empty { + color: var(--muted); + font-size: 13px; + padding: 10px 8px; +} + +.tx-row { cursor: pointer; } +.tx-row td { padding: 10px 8px; } + +.tx-line { + display: grid; + grid-template-columns: 170px 220px minmax(180px, 1fr) minmax(170px, 260px) 130px; + gap: 10px; + align-items: center; + min-width: 720px; +} + +.tx-time { color: var(--muted); font-size: 12px; } + +.tx-action { display: flex; flex-direction: column; line-height: 1.25; } +.tx-action-title { font-weight: 600; font-size: 13px; color: var(--text); } +.tx-action-type { font-size: 11px; color: var(--muted); } + +.tx-memo-block { + font-size: 11px; + color: #b9c7dd; + background: var(--surface2); + border-radius: 6px; + padding: 6px 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tx-memo-block.empty { color: var(--muted); } + +.tx-route { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.tx-route-item { + color: var(--accent); + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 12px; +} + +.tx-route-arrow { color: var(--muted); width: 12px; height: 12px; flex: 0 0 auto; } + +.tx-amount { text-align: right; font-weight: 600; font-size: 13px; } +.tx-amount.pos { color: #41c98a; } +.tx-amount.neg { color: #ff7a87; } +.tx-amount.neutral { color: var(--text); } + +/* ─── Misc ───────────────────────────────────────────────────────────────── */ + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.raw { + margin: 0; + border-radius: 6px; + padding: 10px; + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + background: var(--surface2); +} + +.mono { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 12px; +} + +.badge { + display: inline-block; + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + background: #16223a; +} + +.badge.sent { color: #ffb3bf; } +.badge.recv { color: #8cf0bf; } +.badge.neutral { color: #c9d8f0; } + +.icon { width: 13px; height: 13px; } +.lucide { width: 13px; height: 13px; stroke-width: 1.8; } + + +/* ═══════════════════════════════════════════════════════════════════════════ + INDEX PAGE — Tonviewer-style + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ─── Top Navigation ─────────────────────────────────────────────────────── */ + +.topnav { + position: sticky; + top: 0; + z-index: 100; + background: rgba(11, 18, 32, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--line); +} + +.topnav-inner { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.topnav-brand { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: var(--text); +} + +.topnav-brand:hover { text-decoration: none; } + +.brand-gem { + font-size: 18px; + color: var(--accent); + line-height: 1; +} + +.brand-name { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.topnav-links { + display: flex; + align-items: center; + gap: 4px; +} + +.topnav-links a { + color: var(--muted); + font-size: 13px; + font-weight: 500; + text-decoration: none; + padding: 6px 12px; + border-radius: 6px; + transition: color 0.15s, background 0.15s; +} + +.topnav-links a:hover { + color: var(--text); + background: #1a2a45; + text-decoration: none; +} + +/* ─── Hero Section ───────────────────────────────────────────────────────── */ + +.hero { + background: radial-gradient(ellipse 80% 60% at 50% -10%, #1a2f5a 0%, transparent 70%), + var(--bg); + padding: 64px 24px 40px; + text-align: center; +} + +.hero-inner { + max-width: 680px; + margin: 0 auto; +} + +.hero-title { + margin: 0 0 10px; + font-size: 36px; + font-weight: 700; + letter-spacing: -0.03em; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.hero-gem { + font-size: 32px; + color: var(--accent); +} + +.hero-sub { + margin: 0 0 28px; + color: var(--muted); + font-size: 14px; + letter-spacing: 0.01em; +} + +.hero-search { + position: relative; + display: flex; + align-items: center; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 10px; + padding: 4px 4px 4px 16px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.hero-search:focus-within { + border-color: #2a436c; + box-shadow: 0 0 0 3px rgba(125, 181, 255, 0.08); +} + +.hero-search-icon { + width: 16px; + height: 16px; + color: var(--muted); + flex: 0 0 auto; + margin-right: 10px; +} + +.hero-search input { + flex: 1; + border: none; + background: transparent; + color: var(--text); + font-size: 14px; + font-family: inherit; + padding: 8px 0; + outline: none; + min-width: 0; +} + +.hero-search input::placeholder { color: var(--muted); } + +.btn-hero { + flex: 0 0 auto; + border: none; + border-radius: 7px; + padding: 9px 20px; + background: var(--accent); + color: #05111f; + font-size: 13px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: opacity 0.15s; +} + +.btn-hero:hover { opacity: 0.88; } + +.hero-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 18px; + flex-wrap: wrap; +} + +.pill-link, +.pill-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 16px; + border-radius: 999px; + font-size: 13px; + font-weight: 500; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--line); + text-decoration: none; + transition: color 0.15s, background 0.15s, border-color 0.15s; + cursor: pointer; + font-family: inherit; +} + +.pill-link:hover, +.pill-btn:hover { + color: var(--text); + background: #1a2a45; + border-color: #2a436c; + text-decoration: none; +} + +.pill-link .lucide, +.pill-btn .lucide { + width: 14px; + height: 14px; +} + +.hero-status { + margin-top: 16px; + font-size: 12px; + color: var(--muted); + min-height: 18px; +} + +/* setStatus() sets className="status ok/warn/err" — remap to same look */ +#status.status { margin-top: 16px; font-size: 12px; min-height: 18px; } +#status.status.ok { color: var(--ok); } +#status.status.warn { color: var(--warn); } +#status.status.err { color: var(--err); } + +/* ─── Stats Strip ────────────────────────────────────────────────────────── */ + +.stats-strip { + background: var(--surface); + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.strip-inner { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + display: flex; + align-items: center; + gap: 0; + height: 80px; + overflow-x: auto; +} + +.strip-cell { + flex: 1; + min-width: 130px; + padding: 12px 16px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; +} + +.strip-cell-chart { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + align-items: center; + column-gap: 12px; + row-gap: 2px; +} + +.strip-cell-chart .strip-label { + grid-column: 1; + grid-row: 1; +} + +.strip-cell-chart .strip-val { + grid-column: 1; + grid-row: 2; +} + +.strip-cell-chart .strip-chart { + grid-column: 2; + grid-row: 1 / 3; + align-self: center; +} + +.strip-label { + font-size: 11px; + color: var(--muted); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.strip-val { + font-size: 18px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; +} + +.strip-sub { + font-size: 11px; + color: var(--muted); + white-space: nowrap; +} + +.strip-chart { + width: 80px; + height: 36px; + flex: 0 0 80px; + display: block; +} + +.strip-divider { + width: 1px; + height: 40px; + background: var(--line); + flex: 0 0 1px; +} + +/* ─── Page Body ──────────────────────────────────────────────────────────── */ + +.page-body { + max-width: 1200px; + margin: 0 auto; + padding: 24px; +} + +/* ─── Stat Cards Row ─────────────────────────────────────────────────────── */ + +.cards-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.card { + background: var(--surface); + border-radius: 10px; + padding: 16px; +} + +.card-key { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + color: var(--muted); + font-weight: 500; + margin-bottom: 10px; +} + +.card-key .lucide { + width: 14px; + height: 14px; + flex: 0 0 auto; +} + +.card-val { + font-size: 22px; + font-weight: 700; + line-height: 1.2; + margin-bottom: 4px; +} + +.card-sub { + font-size: 12px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ─── Charts Row ─────────────────────────────────────────────────────────── */ + +.charts-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} + +.charts-row .panel { margin-bottom: 0; } + +/* ─── Block Detail Panel ─────────────────────────────────────────────────── */ + +#blockDetailPanel { } + +/* ─── Responsive (index / shared) ───────────────────────────────────────── */ + +@media (max-width: 1080px) { + .stats-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .split { grid-template-columns: 1fr; } +} + +@media (max-width: 900px) { + .charts-row { grid-template-columns: 1fr; } + .cards-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .tx-line { grid-template-columns: 1fr; min-width: 0; gap: 6px; } + .tx-amount { text-align: left; } +} + + +/* ═══════════════════════════════════════════════════════════════════════════ + ADDRESS / WALLET PAGE + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ─── Search bar strip ───────────────────────────────────────────────────── */ + +.addr-searchbar { + background: var(--surface); + border-bottom: 1px solid var(--line); + padding: 12px 24px; +} + +.addr-searchbar-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* ─── Profile card ───────────────────────────────────────────────────────── */ + +.addr-profile-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + background: var(--surface); + border-radius: 12px; + padding: 24px; + margin-bottom: 14px; +} + +.addr-profile-left { + display: flex; + align-items: flex-start; + gap: 18px; + flex: 1; + min-width: 0; +} + +.addr-avatar { + width: 56px; + height: 56px; + border-radius: 50%; + background: linear-gradient(135deg, #1a3a6e 0%, #0d2050 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + font-weight: 700; + color: var(--accent); + flex: 0 0 56px; + text-transform: uppercase; +} + +.addr-profile-info { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.addr-name { + font-size: 20px; + font-weight: 700; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.addr-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.addr-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.addr-badge-ok { background: rgba(65, 201, 138, 0.12); color: #41c98a; border: 1px solid rgba(65,201,138,0.25); } +.addr-badge-accent { background: rgba(125,181,255, 0.12); color: var(--accent); border: 1px solid rgba(125,181,255,0.25); } +.addr-badge-relay { background: rgba(240,179, 90, 0.12); color: var(--warn); border: 1px solid rgba(240,179,90,0.25); } +.addr-badge-muted { background: rgba(152,167,194,0.08); color: var(--muted); border: 1px solid rgba(152,167,194,0.15); } + +/* balance column (right side of profile card) */ +.addr-profile-right { + flex: 0 0 auto; + text-align: right; +} + +.addr-bal-label { + font-size: 11px; + color: var(--muted); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 4px; +} + +.addr-bal-val { + font-size: 28px; + font-weight: 700; + line-height: 1.1; + color: var(--text); +} + +.addr-bal-sub { + margin-top: 4px; + font-size: 12px; + color: var(--muted); + font-family: "JetBrains Mono", ui-monospace, monospace; +} + +/* ─── Detail KV list ─────────────────────────────────────────────────────── */ + +.addr-detail-panel { padding: 0; } + +.addr-kv-list { display: flex; flex-direction: column; } + +.addr-kv-row { + display: flex; + align-items: center; + gap: 16px; + padding: 13px 20px; + border-bottom: 1px solid var(--line); + min-height: 48px; +} + +.addr-kv-row:last-child { border-bottom: none; } + +.addr-kv-key { + display: flex; + align-items: center; + gap: 7px; + width: 200px; + flex: 0 0 200px; + font-size: 13px; + color: var(--muted); + font-weight: 500; +} + +.addr-kv-key .lucide { width: 14px; height: 14px; flex: 0 0 auto; } + +.addr-kv-val { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + font-size: 13px; + word-break: break-all; +} + +.addr-pubkey-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +/* copy button */ +.copy-btn { + flex: 0 0 auto; + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--muted); + border-radius: 4px; + display: flex; + align-items: center; + transition: color 0.15s, background 0.15s; +} + +.copy-btn:hover { color: var(--text); background: #1a2a45; } +.copy-btn-done { color: var(--ok) !important; } +.copy-btn .lucide { width: 13px; height: 13px; } + +/* ─── Node stats panel ───────────────────────────────────────────────────── */ + +.addr-node-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + background: var(--line); + border-radius: 8px; + overflow: hidden; +} + +.addr-node-cell { + background: var(--surface2); + padding: 14px 16px; +} + +.addr-node-label { + font-size: 11px; + color: var(--muted); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; +} + +.addr-node-val { + font-size: 20px; + font-weight: 700; +} + +.addr-node-sub { + font-size: 11px; + color: var(--muted); + margin-top: 2px; +} + +/* ─── History panel ──────────────────────────────────────────────────────── */ + +.addr-hist-head { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.addr-hist-head h2 { margin-bottom: 0; } + +.addr-hist-count { + font-size: 12px; + color: var(--muted); + font-weight: 500; + background: #1a2a45; + padding: 2px 8px; + border-radius: 999px; +} + +/* tx table columns */ +.tx-type-cell { + display: flex; + align-items: center; + gap: 9px; +} + +.tx-type-icon { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 30px; +} + +.tx-type-icon .lucide { width: 14px; height: 14px; } + +.tx-icon-recv { background: rgba(65,201,138,0.12); color: #41c98a; } +.tx-icon-sent { background: rgba(255,122,135,0.12); color: #ff7a87; } +.tx-icon-neutral { background: rgba(152,167,194,0.1); color: var(--muted); } + +.tx-type-name { font-size: 13px; font-weight: 600; } +.tx-type-raw { font-size: 11px; color: var(--muted); } + +.tx-block-ref { + display: inline-block; + font-size: 11px; + color: var(--muted); + background: #0f1729; + border-radius: 4px; + padding: 2px 6px; + margin-top: 2px; +} + +.tx-fee { + font-size: 11px; + color: var(--muted); + margin-top: 2px; +} + +.addr-load-more { + padding: 12px 0 4px; + display: flex; + justify-content: center; +} + +.addr-load-more .btn { + display: flex; + align-items: center; + gap: 6px; +} + +.addr-load-more .btn .lucide { width: 14px; height: 14px; } + +.text-muted { color: var(--muted); } + +/* ─── Empty / error state ────────────────────────────────────────────────── */ + +.addr-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; + gap: 12px; +} + +.addr-empty-icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: #1a2a45; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; +} + +.addr-empty-icon .lucide { width: 24px; height: 24px; color: var(--muted); } + +.addr-empty-title { + font-size: 16px; + font-weight: 600; + color: var(--text); +} + +.addr-empty-sub { + font-size: 13px; + color: var(--muted); + max-width: 360px; + line-height: 1.5; +} + +.addr-error-banner { + display: flex; + align-items: flex-start; + gap: 14px; + background: rgba(255,122,135,0.08); + border: 1px solid rgba(255,122,135,0.25); + border-radius: 10px; + padding: 16px 20px; + margin-bottom: 16px; +} + +.addr-error-icon { + flex: 0 0 auto; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(255,122,135,0.15); + display: flex; + align-items: center; + justify-content: center; +} + +.addr-error-icon .lucide { width: 16px; height: 16px; color: var(--err); } + +.addr-error-body { min-width: 0; } +.addr-error-title { font-size: 14px; font-weight: 600; color: var(--err); margin-bottom: 3px; } +.addr-error-msg { font-size: 13px; color: var(--muted); word-break: break-all; } + +/* address responsive — consolidated below */ + + +/* ═══════════════════════════════════════════════════════════════════════════ + TRANSACTION PAGE + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ─── Status banner ──────────────────────────────────────────────────────── */ + +.tx-banner { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + background: var(--surface); + border-radius: 12px; + padding: 20px 24px; + margin-bottom: 14px; +} + +.tx-banner-left { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.tx-banner-icon { + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 44px; +} + +.tx-banner-icon .lucide { width: 20px; height: 20px; } + +.tx-banner-ok { background: rgba(65, 201, 138, 0.12); color: #41c98a; } +.tx-banner-warn { background: rgba(240,179,90, 0.12); color: var(--warn); } +.tx-banner-err { background: rgba(255,122,135, 0.12); color: var(--err); } + +.tx-banner-title { + font-size: 16px; + font-weight: 700; + color: #41c98a; + margin-bottom: 4px; +} + +.tx-banner-warn .tx-banner-title { color: var(--warn); } + +.tx-banner-desc { + font-size: 14px; + color: var(--muted); + line-height: 1.4; +} + +.tx-banner-time { + font-size: 12px; + color: var(--muted); + white-space: nowrap; + flex: 0 0 auto; + padding-top: 2px; +} + +/* ─── Overview panel ─────────────────────────────────────────────────────── */ + +.tx-overview-panel { padding: 0; } + +/* tabs */ +.tx-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--line); + padding: 0 20px; +} + +.tx-tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--muted); + font-size: 14px; + font-weight: 500; + font-family: inherit; + padding: 14px 16px 12px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + margin-bottom: -1px; +} + +.tx-tab:hover { color: var(--text); } +.tx-tab-active { color: var(--text); border-bottom-color: var(--accent); } + +/* action table */ +.tx-action-table { padding: 0 20px; } + +.tx-action-hdr, +.tx-action-row { + display: grid; + grid-template-columns: 160px 1fr 1fr 160px; + gap: 16px; + align-items: center; + padding: 12px 0; +} + +.tx-action-hdr { + font-size: 12px; + color: var(--muted); + font-weight: 600; + border-bottom: 1px solid var(--line); +} + +.tx-action-row { + border-bottom: 1px solid var(--line); + font-size: 13px; +} + +.tx-action-cell-action { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; +} + +.tx-action-icon { width: 14px; height: 14px; color: var(--accent); } + +.tx-action-cell-route { display: flex; align-items: center; } + +.tx-action-cell-payload { + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tx-action-cell-value { text-align: right; } + +.tx-overview-amt { + font-size: 14px; + font-weight: 600; + color: var(--text); +} + +/* ─── Flow diagram ───────────────────────────────────────────────────────── */ + +.tx-flow { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + padding: 32px 24px 28px; + min-height: 120px; +} + +.tx-flow-node { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.tx-flow-bubble { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; + text-transform: uppercase; +} + +.tx-flow-from { background: rgba(125,181,255,0.12); color: var(--accent); border: 1.5px solid rgba(125,181,255,0.3); } +.tx-flow-to { background: rgba(65, 201,138,0.12); color: #41c98a; border: 1.5px solid rgba(65,201,138,0.3); } + +.tx-flow-node-label { + font-size: 13px; + font-weight: 500; + max-width: 140px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tx-flow-node-sub { + font-size: 11px; + color: var(--muted); +} + +/* arrow between nodes */ +.tx-flow-arrow { + flex: 1; + min-width: 100px; + max-width: 220px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 0 8px; + position: relative; + top: -8px; /* align with bubble center */ +} + +.tx-flow-arrow-label { + font-size: 12px; + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.tx-flow-arrow-line { + display: flex; + align-items: center; + width: 100%; + gap: 0; +} + +.tx-flow-arrow-track { + flex: 1; + height: 1.5px; + background: linear-gradient(to right, rgba(125,181,255,0.4), rgba(65,201,138,0.4)); +} + +.tx-flow-arrow-tip { + width: 16px; + height: 16px; + color: #41c98a; + flex: 0 0 auto; +} + +.tx-flow-arrow-sub { + font-size: 11px; + color: var(--muted); + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ─── Details KV (reuses addr-kv-* from address page) ───────────────────── */ + +.tx-type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + background: #1a2a45; + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 12px; + color: var(--accent); +} + +.tx-amount-val { + font-size: 15px; + font-weight: 600; +} + +.tx-amount-val.pos { color: #41c98a; } + +.tx-block-hash { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 11px; + color: var(--muted); + margin-left: 8px; +} + +.tx-time-ago { + font-size: 12px; + color: var(--muted); + margin-left: 8px; +} + +/* ─── Raw JSON panel ─────────────────────────────────────────────────────── */ + +.tx-raw-pre { + margin: 0; + max-height: 480px; + overflow-y: auto; +} + +#paneRaw { padding: 16px 20px 20px; } +#paneOverview > .tx-action-table + .tx-flow { /* already has padding */ } + +/* tx responsive — consolidated below */ + + +/* ═══════════════════════════════════════════════════════════════════════════ + LIST PAGES (validators / relays) + ═══════════════════════════════════════════════════════════════════════════ */ + +.list-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.list-page-header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.list-page-icon { + width: 52px; + height: 52px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 52px; +} + +.list-page-icon .lucide { width: 22px; height: 22px; } + +.list-page-icon-val { background: rgba(125,181,255,0.12); color: var(--accent); } +.list-page-icon-relay { background: rgba(240,179, 90,0.12); color: var(--warn); } + +.list-page-title { + margin: 0 0 4px; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.list-page-sub { + margin: 0; + font-size: 13px; + color: var(--muted); + max-width: 480px; +} + +.list-page-header-right { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.list-page-stat { + text-align: center; +} + +.list-page-stat-val { + font-size: 26px; + font-weight: 700; + line-height: 1.1; +} + +.list-page-stat-label { + font-size: 11px; + color: var(--muted); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-top: 2px; +} + +/* list-page responsive — consolidated below */ + + +/* ═══════════════════════════════════════════════════════════════════════════ + CONSOLIDATED RESPONSIVE OVERRIDES + Single source of truth for all mobile breakpoints. + These come last so they win over any earlier declarations. + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ── 700 px — small tablets / large phones landscape ────────────────────── */ +@media (max-width: 700px) { + .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .kv-grid { grid-template-columns: 1fr; } + + /* ── Stats strip: stay as ONE horizontal scrollable row, no wrapping ── */ + .strip-inner { + flex-wrap: nowrap; + height: 68px; + padding: 0 12px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; /* Firefox */ + } + .strip-inner::-webkit-scrollbar { display: none; } /* Chrome/Safari */ + + /* Each cell is auto-sized, not stretchy */ + .strip-cell { flex: 0 0 auto; min-width: 0; padding: 8px 10px; } + .strip-divider { height: 28px; flex: 0 0 1px; } + .strip-val { font-size: 15px; } + .strip-label { font-size: 10px; } + + /* Chart cells: collapse to normal column, hide canvas (too small) */ + .strip-cell-chart { + display: flex; + flex-direction: column; + gap: 2px; + } + .strip-chart { display: none; } + .strip-cell-chart .strip-label { grid-column: unset; grid-row: unset; } + .strip-cell-chart .strip-val { grid-column: unset; grid-row: unset; } +} + +/* ── 600 px — phones portrait ────────────────────────────────────────────── */ +@media (max-width: 600px) { + /* ── Global spacing ─────────────────────────────────────────────────── */ + .page-body { padding: 12px; } + .panel { padding: 12px; margin-bottom: 10px; border-radius: 8px; } + .topnav-inner { padding: 0 12px; height: 50px; } + .addr-searchbar { padding: 10px 12px; } + + /* ── Index hero ─────────────────────────────────────────────────────── */ + .hero { padding: 28px 12px 18px; } + .hero-title { font-size: 22px; gap: 8px; } + .hero-gem { font-size: 22px; } + .hero-sub { font-size: 12px; margin-bottom: 16px; } + .hero-actions { gap: 6px; margin-top: 12px; } + .pill-link, + .pill-btn { padding: 6px 11px; font-size: 12px; } + .pill-link .lucide, + .pill-btn .lucide { width: 12px; height: 12px; } + + /* ── Stat cards ─────────────────────────────────────────────────────── */ + .cards-row { grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; } + .card { padding: 12px; border-radius: 8px; } + .card-key { font-size: 11px; margin-bottom: 6px; } + .card-val { font-size: 18px; } + .card-sub { font-size: 11px; } + + /* ── Address / Node: profile card ──────────────────────────────────── */ + .addr-profile-card { flex-direction: column; padding: 14px; gap: 14px; border-radius: 8px; } + .addr-profile-right { text-align: left; } + .addr-bal-val { font-size: 22px; } + .addr-bal-sub { font-size: 11px; } + + /* ── KV list rows ───────────────────────────────────────────────────── */ + .addr-kv-row { padding: 10px 14px; } + .addr-kv-key { width: 130px; flex: 0 0 130px; font-size: 12px; } + .addr-kv-key .lucide { width: 13px; height: 13px; } + + /* ── Node stats grid ────────────────────────────────────────────────── */ + .addr-node-grid { grid-template-columns: 1fr 1fr; } + .addr-node-cell { padding: 10px 12px; } + .addr-node-val { font-size: 17px; } + .addr-node-label { font-size: 10px; } + + /* ── TX page ────────────────────────────────────────────────────────── */ + .tx-banner { padding: 12px 14px; flex-direction: column; gap: 10px; border-radius: 8px; } + .tx-banner-time { padding-top: 0; font-size: 11px; } + .tx-banner-left { gap: 12px; } + .tx-banner-icon { width: 36px; height: 36px; flex: 0 0 36px; } + .tx-banner-icon .lucide { width: 16px; height: 16px; } + .tx-banner-title { font-size: 14px; } + .tx-banner-desc { font-size: 13px; } + .tx-tabs { padding: 0 12px; } + .tx-tab { padding: 12px 12px 10px; font-size: 13px; } + .tx-action-table { padding: 0 12px; } + .tx-action-hdr, + .tx-action-row { grid-template-columns: 110px 1fr; gap: 10px; padding: 10px 0; } + .tx-action-cell-payload, + .tx-action-cell-value { display: none; } + .tx-flow { padding: 18px 12px; flex-direction: column; gap: 16px; } + .tx-flow-arrow { transform: rotate(90deg); min-width: 60px; max-width: 80px; top: 0; padding: 0; } + .tx-flow-node-label { max-width: 200px; font-size: 12px; } + #paneRaw { padding: 12px; } + + /* ── List pages (validators / relays) ──────────────────────────────── */ + .list-page-header { flex-direction: column; gap: 12px; } + .list-page-header-right { width: 100%; justify-content: flex-start; } + .list-page-title { font-size: 18px; } + .list-page-sub { font-size: 12px; } + .list-page-icon { width: 40px; height: 40px; flex: 0 0 40px; border-radius: 8px; } + .list-page-icon .lucide { width: 18px; height: 18px; } + .list-page-stat-val { font-size: 20px; } +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Tokens & NFTs + ═══════════════════════════════════════════════════════════════════════════ */ + +/* Token symbol pill */ +.token-sym { + display: inline-block; + background: rgba(125, 181, 255, 0.12); + color: var(--accent); + border: 1px solid rgba(125, 181, 255, 0.22); + border-radius: 6px; + padding: 2px 9px; + font-family: "JetBrains Mono", monospace; + font-size: 13px; + font-weight: 600; + text-decoration: none; + letter-spacing: 0.04em; +} +.token-sym:hover { background: rgba(125, 181, 255, 0.2); } + +/* List page icon — token variant */ +.list-page-icon-token { + background: rgba(125, 181, 255, 0.12); + color: var(--accent); +} + +/* NFT grid */ +.nft-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + padding: 0; +} + +.nft-card { + background: var(--surface2); + border: 1px solid var(--line); + border-radius: 12px; + overflow: hidden; + text-decoration: none; + color: var(--text); + transition: border-color 0.15s, transform 0.12s; + display: block; +} +.nft-card:hover { border-color: var(--accent); transform: translateY(-2px); } +.nft-card-burned { opacity: 0.45; } + +.nft-card-img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + display: block; + background: var(--surface); +} +.nft-card-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 40px; + background: var(--surface); +} +.nft-card-body { padding: 10px 12px 12px; } +.nft-card-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; } +.nft-card-id { font-size: 11px; color: var(--muted); font-family: monospace; } +.nft-card-owner{ font-size: 11px; color: var(--muted); margin-top: 4px; } + +.badge-burned { + background: rgba(255, 122, 135, 0.15); + color: var(--err); + border-radius: 4px; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; + vertical-align: middle; + margin-left: 4px; +} + +/* Token/NFT detail — attributes grid */ +.attrs-grid { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.attr-chip { + background: var(--surface2); + border: 1px solid var(--line); + border-radius: 8px; + padding: 6px 12px; + min-width: 90px; +} +.attr-chip-key { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin-bottom: 2px; +} +.attr-chip-val { + font-size: 14px; + font-weight: 600; + color: var(--accent); +} + +/* ── 400 px — tiny phones ────────────────────────────────────────────────── */ +@media (max-width: 400px) { + .page-body { padding: 8px; } + .panel { padding: 10px; } + .topnav-inner { padding: 0 8px; } + .topnav-links { display: none; } /* hide nav links, logo still visible */ + + .cards-row { grid-template-columns: 1fr; } + + /* KV list: stack label above value */ + .addr-kv-row { flex-direction: column; align-items: flex-start; gap: 3px; padding: 8px 12px; } + .addr-kv-key { width: auto; flex: none; } + + .hero-title { font-size: 19px; } + .hero { padding: 22px 8px 14px; } + .addr-node-grid { grid-template-columns: 1fr 1fr; } + .addr-profile-card { padding: 12px; } + + th, td { padding: 7px 8px; font-size: 12px; } +} diff --git a/node/explorer/token.html b/node/explorer/token.html new file mode 100644 index 0000000..806c5de --- /dev/null +++ b/node/explorer/token.html @@ -0,0 +1,89 @@ + + + + + +Token | DChain Explorer + + + + + + + + + + +
+ +
+ + + +
+ + + + + + + + +
+ +
+ + +
+ +
+
+ +
+ + + +
+ + + +
+ +
+ + diff --git a/node/explorer/token.js b/node/explorer/token.js new file mode 100644 index 0000000..369451f --- /dev/null +++ b/node/explorer/token.js @@ -0,0 +1,180 @@ +(function() { + var C = window.ExplorerCommon; + + var params = new URLSearchParams(window.location.search); + var tokenID = params.get('id'); + var nftID = params.get('nft'); + + function kv(icon, label, valHtml) { + return '
' + + '
' + label + '
' + + '
' + valHtml + '
' + + '
'; + } + + function copyBtn(id) { + return ''; + } + + function hiddenSpan(id, val) { + return ''; + } + + /* ── Fungible token ──────────────────────────────────────────────────────── */ + + async function loadToken() { + C.setStatus('Loading token…', 'warn'); + try { + var data = await C.fetchJSON('/api/tokens/' + tokenID); + if (!data || !data.token_id) { C.setStatus('Token not found.', 'err'); return; } + + document.title = data.symbol + ' | DChain Explorer'; + document.getElementById('bannerType').textContent = 'Fungible Token'; + document.getElementById('bannerName').textContent = data.name + ' (' + data.symbol + ')'; + document.getElementById('bannerBlock').textContent = 'block ' + (data.issued_at || 0); + document.getElementById('bannerIcon').querySelector('i').setAttribute('data-lucide', 'coins'); + + var d = data.decimals || 0; + var supplyFmt = formatSupply(data.total_supply || 0, d); + + var rows = ''; + rows += kv('hash', 'Token ID', + '' + C.esc(data.token_id) + '' + copyBtn('tid')); + rows += kv('type', 'Symbol', + '' + C.esc(data.symbol) + ''); + rows += kv('tag', 'Name', C.esc(data.name)); + rows += kv('layers-3', 'Decimals', '' + d + ''); + rows += kv('bar-chart-2', 'Total Supply', + '' + C.esc(supplyFmt) + ''); + rows += kv('user', 'Issuer', + '' + + C.short(data.issuer, 20) + + '' + + hiddenSpan('issuerRaw', data.issuer) + copyBtn('issuerRaw')); + rows += kv('blocks', 'Issued at block', + '' + (data.issued_at || 0) + ''); + + document.getElementById('kvList').innerHTML = rows; + document.getElementById('rawJSON').textContent = JSON.stringify(data, null, 2); + document.getElementById('tabRaw').style.display = ''; + + document.getElementById('mainContent').style.display = ''; + C.setStatus('', ''); + C.refreshIcons(); + C.wireClipboard(); + } catch(e) { + C.setStatus('Load failed: ' + e.message, 'err'); + } + } + + /* ── NFT ─────────────────────────────────────────────────────────────────── */ + + async function loadNFT() { + C.setStatus('Loading NFT…', 'warn'); + try { + var data = await C.fetchJSON('/api/nfts/' + nftID); + var n = data && data.nft ? data.nft : data; + if (!n || !n.nft_id) { C.setStatus('NFT not found.', 'err'); return; } + + document.title = n.name + ' | DChain Explorer'; + document.getElementById('bannerType').textContent = n.burned ? 'NFT (Burned)' : 'NFT'; + document.getElementById('bannerName').textContent = n.name; + document.getElementById('bannerBlock').textContent = 'minted block ' + (n.minted_at || 0); + document.getElementById('bannerIcon').querySelector('i').setAttribute('data-lucide', 'image'); + if (n.burned) document.getElementById('banner').classList.add('tx-banner-err'); + + // Show image if URI looks like an image + if (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri)) { + document.getElementById('nftImage').src = n.uri; + document.getElementById('nftImageWrap').style.display = ''; + } + + var ownerAddr = data.owner_address || ''; + var rows = ''; + rows += kv('hash', 'NFT ID', + '' + C.esc(n.nft_id) + '' + copyBtn('nid')); + rows += kv('tag', 'Name', C.esc(n.name)); + if (n.description) + rows += kv('file-text', 'Description', C.esc(n.description)); + if (n.uri) + rows += kv('link', 'Metadata URI', + '' + C.esc(n.uri) + ''); + if (!n.burned && n.owner) { + rows += kv('user', 'Owner', + '' + + C.short(ownerAddr || n.owner, 20) + + '' + + hiddenSpan('ownerRaw', n.owner) + copyBtn('ownerRaw')); + } else if (n.burned) { + rows += kv('flame', 'Status', 'Burned'); + } + rows += kv('user-check', 'Issuer', + '' + + C.short(n.issuer, 20) + + ''); + rows += kv('blocks', 'Minted at block', + '' + (n.minted_at || 0) + ''); + + document.getElementById('kvList').innerHTML = rows; + + // Attributes + if (n.attributes) { + try { + var attrs = JSON.parse(n.attributes); + var keys = Object.keys(attrs); + if (keys.length) { + var html = ''; + keys.forEach(function(k) { + html += '
' + C.esc(k) + '
' + + '
' + C.esc(String(attrs[k])) + '
'; + }); + document.getElementById('attrsGrid').innerHTML = html; + document.getElementById('attrsSection').style.display = ''; + } + } catch(_) {} + } + + document.getElementById('rawJSON').textContent = JSON.stringify(data, null, 2); + document.getElementById('tabRaw').style.display = ''; + document.getElementById('mainContent').style.display = ''; + C.setStatus('', ''); + C.refreshIcons(); + C.wireClipboard(); + } catch(e) { + C.setStatus('Load failed: ' + e.message, 'err'); + } + } + + /* ── Helpers ─────────────────────────────────────────────────────────────── */ + + function formatSupply(supply, decimals) { + if (decimals === 0) return supply.toLocaleString(); + var d = Math.pow(10, decimals); + var whole = Math.floor(supply / d); + var frac = supply % d; + if (frac === 0) return whole.toLocaleString(); + return whole.toLocaleString() + '.' + String(frac).padStart(decimals, '0').replace(/0+$/, ''); + } + + /* ── Tabs ────────────────────────────────────────────────────────────────── */ + + document.getElementById('tabOverview').addEventListener('click', function() { + document.getElementById('paneOverview').style.display = ''; + document.getElementById('paneRaw').style.display = 'none'; + document.getElementById('tabOverview').classList.add('tx-tab-active'); + document.getElementById('tabRaw').classList.remove('tx-tab-active'); + }); + document.getElementById('tabRaw').addEventListener('click', function() { + document.getElementById('paneOverview').style.display = 'none'; + document.getElementById('paneRaw').style.display = ''; + document.getElementById('tabRaw').classList.add('tx-tab-active'); + document.getElementById('tabOverview').classList.remove('tx-tab-active'); + }); + + /* ── Boot ────────────────────────────────────────────────────────────────── */ + + if (nftID) loadNFT(); + else if (tokenID) loadToken(); + else C.setStatus('No token or NFT ID provided.', 'err'); + +})(); diff --git a/node/explorer/tokens.html b/node/explorer/tokens.html new file mode 100644 index 0000000..8367679 --- /dev/null +++ b/node/explorer/tokens.html @@ -0,0 +1,100 @@ + + + + + +Tokens & NFTs | DChain Explorer + + + + + + + + + + +
+ +
+ +
+ + +
+
+
+
+

Tokens & NFTs

+

+ Fungible tokens issued on-chain and non-fungible token collections. +

+
+
+
+
+
+
Fungible
+
+
+
+
NFTs
+
+ +
+
+ +
+ + +
+ + +
+ + +
+
+
+ + + + + + + + + + + + + + + +
#SymbolNameDecimalsTotal SupplyIssuerBlock
Loading…
+
+
+
+ + + + +
+ + diff --git a/node/explorer/tokens.js b/node/explorer/tokens.js new file mode 100644 index 0000000..572f0a4 --- /dev/null +++ b/node/explorer/tokens.js @@ -0,0 +1,121 @@ +(function() { + var C = window.ExplorerCommon; + + var state = { tab: 'fungible' }; + + function switchTab(name) { + state.tab = name; + document.getElementById('paneFungible').style.display = name === 'fungible' ? '' : 'none'; + document.getElementById('paneNFT').style.display = name === 'nft' ? '' : 'none'; + document.getElementById('tabFungible').className = 'tx-tab' + (name === 'fungible' ? ' tx-tab-active' : ''); + document.getElementById('tabNFT').className = 'tx-tab' + (name === 'nft' ? ' tx-tab-active' : ''); + } + + /* ── Fungible tokens ─────────────────────────────────────────────────────── */ + + function formatSupply(supply, decimals) { + if (decimals === 0) return supply.toLocaleString(); + var d = Math.pow(10, decimals); + var whole = Math.floor(supply / d); + var frac = supply % d; + if (frac === 0) return whole.toLocaleString(); + return whole.toLocaleString() + '.' + String(frac).padStart(decimals, '0').replace(/0+$/, ''); + } + + async function loadTokens() { + C.setStatus('Loading…', 'warn'); + try { + var data = await C.fetchJSON('/api/tokens'); + var tokens = (data && Array.isArray(data.tokens)) ? data.tokens : []; + document.getElementById('tokenCount').textContent = tokens.length; + + var tbody = document.getElementById('tokenBody'); + if (!tokens.length) { + tbody.innerHTML = 'No fungible tokens issued yet.'; + } else { + var rows = ''; + tokens.forEach(function(t, i) { + var supply = formatSupply(t.total_supply || 0, t.decimals || 0); + rows += + '' + + '' + (i+1) + '' + + '' + + '' + + C.esc(t.symbol) + + '' + + '' + + '' + C.esc(t.name) + '' + + '' + (t.decimals || 0) + '' + + '' + C.esc(supply) + '' + + '' + + '' + + C.short(t.issuer, 20) + + '' + + '' + + '' + (t.issued_at || 0) + '' + + ''; + }); + tbody.innerHTML = rows; + } + C.refreshIcons(); + } catch(e) { + C.setStatus('Load failed: ' + e.message, 'err'); + } + } + + /* ── NFTs ────────────────────────────────────────────────────────────────── */ + + async function loadNFTs() { + try { + var data = await C.fetchJSON('/api/nfts'); + var nfts = (data && Array.isArray(data.nfts)) ? data.nfts : []; + document.getElementById('nftCount').textContent = nfts.length; + + var grid = document.getElementById('nftGrid'); + var empty = document.getElementById('nftEmpty'); + if (!nfts.length) { + grid.innerHTML = ''; + empty.style.display = ''; + return; + } + empty.style.display = 'none'; + + var cards = ''; + nfts.forEach(function(n) { + var burned = n.burned ? ' nft-card-burned' : ''; + var imgHtml = ''; + if (n.uri && /\.(png|jpg|jpeg|gif|svg|webp)/i.test(n.uri)) { + imgHtml = ''; + } else { + imgHtml = '
'; + } + cards += + '' + + imgHtml + + '
' + + '
' + C.esc(n.name) + (n.burned ? ' BURNED' : '') + '
' + + '
' + C.short(n.nft_id, 16) + '
' + + (n.owner ? '
Owner: ' + C.short(n.owner, 16) + '
' : '') + + '
' + + '
'; + }); + grid.innerHTML = cards; + C.refreshIcons(); + } catch(e) { + document.getElementById('nftCount').textContent = '?'; + } + } + + /* ── Boot ────────────────────────────────────────────────────────────────── */ + + document.getElementById('tabFungible').addEventListener('click', function() { switchTab('fungible'); }); + document.getElementById('tabNFT').addEventListener('click', function() { switchTab('nft'); }); + document.getElementById('refreshBtn').addEventListener('click', function() { loadTokens(); loadNFTs(); }); + + // Check URL hash for tab. + if (window.location.hash === '#nft') switchTab('nft'); + + Promise.all([loadTokens(), loadNFTs()]).then(function() { + C.setStatus('', ''); + }); +})(); diff --git a/node/explorer/tx.html b/node/explorer/tx.html new file mode 100644 index 0000000..c71a4c5 --- /dev/null +++ b/node/explorer/tx.html @@ -0,0 +1,186 @@ + + + + + +Transaction | DChain Explorer + + + + + + + + + + + +
+ +
+ + + + +
+ + +
+
+
+ +
+
+
Confirmed transaction
+
+
+
+
+
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ Action + Route + Payload / Memo + Value +
+
+ +
+
+ + +
+ +
+ +
+ + + + +
+ + +
+
+ +
+
Transaction ID
+
+ + +
+
+ +
+
Type
+
+
+ + + +
+
From
+
+ + + +
+
+ + + +
+
Amount
+
+
+ +
+
Fee
+
+
+ +
+
Block
+
+ + +
+
+ +
+
Time
+
+ + +
+
+ +
+
Signature
+
+ + +
+
+ +
+
+ + + + +
+ + + diff --git a/node/explorer/tx.js b/node/explorer/tx.js new file mode 100644 index 0000000..c56f7ff --- /dev/null +++ b/node/explorer/tx.js @@ -0,0 +1,334 @@ +(function() { + var C = window.ExplorerCommon; + + /* ── Helpers ─────────────────────────────────────────────────────────────── */ + + function linkAddress(pubkey, addrDisplay) { + var label = addrDisplay || C.shortAddress(pubkey); + return '' + C.esc(label) + ''; + } + + function txTypeIcon(type) { + var map = { + TRANSFER: 'arrow-left-right', + REGISTER_KEY: 'user-check', + RELAY_PROOF: 'zap', + HEARTBEAT: 'activity', + BLOCK_REWARD: 'layers-3', + BIND_WALLET: 'link', + REGISTER_RELAY: 'radio', + SLASH: 'alert-triangle', + ADD_VALIDATOR: 'shield-plus', + REMOVE_VALIDATOR: 'shield-minus', + }; + return map[type] || 'circle'; + } + + // Color class for the banner icon based on tx type + function txBannerClass(type) { + var pos = { BLOCK_REWARD: 1, RELAY_PROOF: 1, HEARTBEAT: 1, REGISTER_KEY: 1, REGISTER_RELAY: 1 }; + var warn = { SLASH: 1, REMOVE_VALIDATOR: 1 }; + if (warn[type]) return 'tx-banner-warn'; + if (pos[type]) return 'tx-banner-ok'; + return 'tx-banner-ok'; // default — all confirmed txs are "ok" + } + + // One-line human description of the tx + function txDescription(tx) { + var fromAddr = tx.from_addr ? C.shortAddress(tx.from_addr) : (tx.from ? C.shortAddress(tx.from) : '?'); + var toAddr = tx.to_addr ? C.shortAddress(tx.to_addr) : (tx.to ? C.shortAddress(tx.to) : ''); + var amt = tx.amount ? tx.amount : C.toToken(tx.amount_ut || 0); + + switch (String(tx.type)) { + case 'TRANSFER': + return fromAddr + ' sent ' + amt + (toAddr ? ' to ' + toAddr : ''); + case 'BLOCK_REWARD': + return 'Block #' + tx.block_index + ' fee reward' + (toAddr ? ' to ' + toAddr : ''); + case 'RELAY_PROOF': + return fromAddr + ' submitted relay proof, earned ' + amt; + case 'HEARTBEAT': + return fromAddr + ' submitted heartbeat'; + case 'REGISTER_KEY': { + var nick = ''; + if (tx.payload && tx.payload.nickname) nick = ' as "' + tx.payload.nickname + '"'; + return fromAddr + ' registered identity' + nick; + } + case 'REGISTER_RELAY': + return fromAddr + ' registered as relay node'; + case 'BIND_WALLET': + return fromAddr + ' bound wallet' + (toAddr ? ' → ' + toAddr : ''); + case 'ADD_VALIDATOR': + return fromAddr + ' added ' + (toAddr || '?') + ' to validator set'; + case 'REMOVE_VALIDATOR': + return fromAddr + ' removed ' + (toAddr || '?') + ' from validator set'; + case 'SLASH': + return fromAddr + ' slashed ' + (toAddr || '?'); + default: + return C.txLabel(tx.type) + (fromAddr ? ' by ' + fromAddr : ''); + } + } + + /* ── Flow diagram builder ─────────────────────────────────────────────── */ + + function buildFlowDiagram(tx) { + var fromPub = tx.from || ''; + var fromAddr = tx.from_addr || (fromPub ? C.shortAddress(fromPub) : ''); + var toPub = tx.to || ''; + var toAddr = tx.to_addr || (toPub ? C.shortAddress(toPub) : ''); + var amt = tx.amount ? tx.amount : (tx.amount_ut ? C.toToken(tx.amount_ut) : ''); + var memo = tx.memo || ''; + + // Nodes + var fromNode = ''; + var toNode = ''; + + if (fromPub) { + fromNode = + '
' + + '
' + + (fromAddr ? fromAddr[0].toUpperCase() : '?') + + '
' + + '
' + + (fromPub ? '' + C.esc(fromAddr || C.shortAddress(fromPub)) + '' : '—') + + '
' + + '
Sender
' + + '
'; + } + + if (toPub) { + toNode = + '
' + + '
' + + (toAddr ? toAddr[0].toUpperCase() : '?') + + '
' + + '' + + '
Recipient
' + + '
'; + } + + // No route for system txs with no To + if (!fromPub && !toPub) return ''; + + var arrowLabel = amt || C.txLabel(tx.type); + var arrowSub = memo || ''; + + var arrow = + '
' + + '
' + C.esc(arrowLabel) + '
' + + '
' + + '
' + + '' + + '
' + + (arrowSub ? '
' + C.esc(arrowSub) + '
' : '') + + '
'; + + if (fromNode && toNode) { + return fromNode + arrow + toNode; + } + if (fromNode) return fromNode; + return toNode; + } + + /* ── Main render ─────────────────────────────────────────────────────────── */ + + function renderTx(tx) { + // Page title + document.title = C.txLabel(tx.type) + ' ' + C.short(tx.id, 12) + ' | DChain Explorer'; + + // ── Banner + var bannerIcon = document.getElementById('txBannerIcon'); + bannerIcon.innerHTML = ''; + bannerIcon.className = 'tx-banner-icon ' + txBannerClass(tx.type); + document.getElementById('txBannerTitle').textContent = 'Confirmed · ' + C.txLabel(tx.type); + document.getElementById('txBannerDesc').textContent = txDescription(tx); + document.getElementById('txBannerTime').textContent = + C.fmtTime(tx.time) + ' (' + C.timeAgo(tx.time) + ')'; + + // ── Overview action table row + var fromAddr = tx.from_addr || (tx.from ? C.shortAddress(tx.from) : '—'); + var toAddr = tx.to_addr || (tx.to ? C.shortAddress(tx.to) : ''); + var routeHtml = tx.from + ? ('' + (tx.from ? linkAddress(tx.from, fromAddr) : '—') + '' + + (tx.to ? '' + + '' + linkAddress(tx.to, toAddr) + '' : '')) + : '—'; + var payloadNote = tx.memo || (tx.payload && tx.payload.nickname ? 'nickname: ' + tx.payload.nickname : '') || '—'; + var amtHtml = tx.amount_ut + ? '' + C.esc(tx.amount || C.toToken(tx.amount_ut)) + '' + : ''; + + document.getElementById('txActionRow').innerHTML = + '
' + + '' + + '' + C.esc(C.txLabel(tx.type)) + '' + + '
' + + '
' + routeHtml + '
' + + '
' + C.esc(payloadNote) + '
' + + '
' + amtHtml + '
'; + + // ── Flow diagram + var flowHtml = buildFlowDiagram(tx); + var flowEl = document.getElementById('txFlow'); + if (flowHtml) { + flowEl.innerHTML = flowHtml; + flowEl.style.display = ''; + } else { + flowEl.style.display = 'none'; + } + + // ── Details panel + // TX ID + document.getElementById('txId').textContent = tx.id || '—'; + + // Type with badge + document.getElementById('txType').innerHTML = + '' + C.esc(tx.type || '—') + '' + + ' — ' + C.esc(C.txLabel(tx.type)) + ''; + + // Memo + if (tx.memo) { + document.getElementById('txMemo').textContent = tx.memo; + document.getElementById('txMemoRow').style.display = ''; + } else { + document.getElementById('txMemoRow').style.display = 'none'; + } + + // From + if (tx.from) { + document.getElementById('txFrom').innerHTML = linkAddress(tx.from, tx.from_addr || tx.from); + document.getElementById('txFromRaw').textContent = tx.from; + } else { + document.getElementById('txFrom').textContent = '—'; + } + + // To + if (tx.to) { + document.getElementById('txTo').innerHTML = linkAddress(tx.to, tx.to_addr || tx.to); + document.getElementById('txToRaw').textContent = tx.to; + document.getElementById('txToRow').style.display = ''; + } else { + document.getElementById('txToRow').style.display = 'none'; + } + + // Amount + var amountText = tx.amount || C.toToken(tx.amount_ut || 0); + document.getElementById('txAmount').textContent = amountText; + document.getElementById('txAmount').className = 'tx-amount-val' + + (tx.amount_ut > 0 ? ' pos' : ''); + + // Fee + document.getElementById('txFee').textContent = tx.fee || C.toToken(tx.fee_ut || 0); + + // Block + var blockLink = document.getElementById('txBlockLink'); + if (tx.block_index !== undefined) { + blockLink.textContent = '#' + tx.block_index; + blockLink.href = '#'; + blockLink.onclick = function(e) { e.preventDefault(); window.location.href = '/?block=' + tx.block_index; }; + } else { + blockLink.textContent = '—'; + } + document.getElementById('txBlockHash').textContent = tx.block_hash + ? ' ' + C.short(tx.block_hash, 20) + : ''; + + // Time + document.getElementById('txTime').textContent = C.fmtTime(tx.time); + document.getElementById('txTimeAgo').textContent = tx.time ? '(' + C.timeAgo(tx.time) + ')' : ''; + + // Signature + if (tx.signature_hex) { + document.getElementById('txSig').textContent = tx.signature_hex; + document.getElementById('txSigRow').style.display = ''; + } else { + document.getElementById('txSigRow').style.display = 'none'; + } + + // ── Payload panel (only for rich payloads) + var shouldShowPayload = tx.payload && + typeof tx.payload === 'object' && + tx.type !== 'TRANSFER'; // memo already shown for transfers + if (shouldShowPayload) { + document.getElementById('txPayloadPre').textContent = JSON.stringify(tx.payload, null, 2); + document.getElementById('txPayloadPanel').style.display = ''; + } else { + document.getElementById('txPayloadPanel').style.display = 'none'; + } + + // ── Raw JSON + document.getElementById('txRaw').textContent = JSON.stringify(tx, null, 2); + + // Show content + document.getElementById('mainContent').style.display = ''; + C.refreshIcons(); + } + + /* ── Load ────────────────────────────────────────────────────────────────── */ + + async function loadTx(id) { + if (!id) return; + C.setStatus('Loading…', 'warn'); + document.getElementById('mainContent').style.display = 'none'; + try { + var tx = await C.fetchJSON('/api/tx/' + encodeURIComponent(id)); + renderTx(tx); + window.history.replaceState({}, '', '/tx?id=' + encodeURIComponent(id)); + C.setStatus('', ''); + } catch (e) { + C.setStatus('Load failed: ' + e.message, 'err'); + } + } + + /* ── Tab switching ───────────────────────────────────────────────────────── */ + + function switchTab(active) { + var tabs = ['Overview', 'Raw']; + tabs.forEach(function(name) { + var btn = document.getElementById('tab' + name); + var pane = document.getElementById('pane' + name); + if (!btn || !pane) return; + var isActive = (name === active); + btn.className = 'tx-tab' + (isActive ? ' tx-tab-active' : ''); + pane.style.display = isActive ? '' : 'none'; + }); + } + + document.getElementById('tabOverview').addEventListener('click', function() { switchTab('Overview'); }); + document.getElementById('tabRaw').addEventListener('click', function() { switchTab('Raw'); }); + + /* ── Copy buttons ────────────────────────────────────────────────────────── */ + + document.addEventListener('click', function(e) { + var t = e.target; + if (!t) return; + var btn = t.closest ? t.closest('.copy-btn') : null; + if (btn) { + var src = document.getElementById(btn.dataset.copyId); + if (src) { + navigator.clipboard.writeText(src.textContent || '').catch(function() {}); + btn.classList.add('copy-btn-done'); + setTimeout(function() { btn.classList.remove('copy-btn-done'); }, 1200); + } + } + }); + + /* ── Wiring ──────────────────────────────────────────────────────────────── */ + + document.getElementById('txBtn').addEventListener('click', function() { + var val = (document.getElementById('txInput').value || '').trim(); + if (val) loadTx(val); + }); + + document.getElementById('txInput').addEventListener('keydown', function(e) { + if (e.key === 'Enter') document.getElementById('txBtn').click(); + }); + + var initial = C.q('id'); + if (initial) { + document.getElementById('txInput').value = initial; + loadTx(initial); + } + +})(); diff --git a/node/explorer/validators.html b/node/explorer/validators.html new file mode 100644 index 0000000..09c7d0a --- /dev/null +++ b/node/explorer/validators.html @@ -0,0 +1,81 @@ + + + + + +Validators | DChain Explorer + + + + + + + + + + +
+ +
+ +
+ + +
+
+
+
+

Validator Set

+

+ Active validators participating in PBFT consensus. + Quorum requires ⌊2/3 · N⌋ + 1 votes. +

+
+
+
+
+
+
Validators
+
+
+
+
Quorum
+
+ +
+
+ +
+ +
+
+ + + + + + + + + + + + +
#AddressPublic KeyActions
Loading…
+
+
+ +
+ + diff --git a/node/explorer/validators.js b/node/explorer/validators.js new file mode 100644 index 0000000..b792423 --- /dev/null +++ b/node/explorer/validators.js @@ -0,0 +1,58 @@ +(function() { + var C = window.ExplorerCommon; + + async function loadValidators() { + C.setStatus('Loading…', 'warn'); + try { + var data = await C.fetchJSON('/api/validators'); + var validators = (data && Array.isArray(data.validators)) ? data.validators : []; + var quorum = validators.length > 0 ? Math.floor(2 * validators.length / 3) + 1 : 0; + + document.getElementById('valCount').textContent = validators.length; + document.getElementById('quorumCount').textContent = quorum || '—'; + + var tbody = document.getElementById('valBody'); + if (!validators.length) { + tbody.innerHTML = 'No validators found.'; + C.setStatus('No validators registered.', 'warn'); + return; + } + + var rows = ''; + validators.forEach(function(v, i) { + var addr = v.address || '—'; + var pubKey = v.pub_key || '—'; + rows += + '' + + '' + (i + 1) + '' + + '' + + '' + + C.esc(addr) + + '' + + '' + + '' + + C.esc(C.short(pubKey, 32)) + + '' + + '' + + '' + + ' Node' + + '' + + '' + + ''; + }); + tbody.innerHTML = rows; + + C.setStatus( + validators.length + ' validator' + (validators.length !== 1 ? 's' : '') + + ' · quorum: ' + quorum, + 'ok' + ); + C.refreshIcons(); + } catch (e) { + C.setStatus('Load failed: ' + e.message, 'err'); + } + } + + document.getElementById('refreshBtn').addEventListener('click', loadValidators); + loadValidators(); +})(); diff --git a/node/explorer_assets.go b/node/explorer_assets.go new file mode 100644 index 0000000..d7b3fb1 --- /dev/null +++ b/node/explorer_assets.go @@ -0,0 +1,166 @@ +package node + +import ( + _ "embed" + "net/http" +) + +//go:embed explorer/index.html +var explorerIndexHTML string + +//go:embed explorer/address.html +var explorerAddressHTML string + +//go:embed explorer/tx.html +var explorerTxHTML string + +//go:embed explorer/node.html +var explorerNodeHTML string + +//go:embed explorer/style.css +var explorerStyleCSS string + +//go:embed explorer/common.js +var explorerCommonJS string + +//go:embed explorer/app.js +var explorerAppJS string + +//go:embed explorer/address.js +var explorerAddressJS string + +//go:embed explorer/tx.js +var explorerTxJS string + +//go:embed explorer/node.js +var explorerNodeJS string + +//go:embed explorer/relays.html +var explorerRelaysHTML string + +//go:embed explorer/relays.js +var explorerRelaysJS string + +//go:embed explorer/validators.html +var explorerValidatorsHTML string + +//go:embed explorer/validators.js +var explorerValidatorsJS string + +//go:embed explorer/contract.html +var explorerContractHTML string + +//go:embed explorer/contract.js +var explorerContractJS string + +//go:embed explorer/tokens.html +var explorerTokensHTML string + +//go:embed explorer/tokens.js +var explorerTokensJS string + +//go:embed explorer/token.html +var explorerTokenHTML string + +//go:embed explorer/token.js +var explorerTokenJS string + +func serveExplorerIndex(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerIndexHTML)) +} + +func serveExplorerAddressPage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerAddressHTML)) +} + +func serveExplorerTxPage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerTxHTML)) +} + +func serveExplorerNodePage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerNodeHTML)) +} + +func serveExplorerCSS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + _, _ = w.Write([]byte(explorerStyleCSS)) +} + +func serveExplorerCommonJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerCommonJS)) +} + +func serveExplorerJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerAppJS)) +} + +func serveExplorerAddressJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerAddressJS)) +} + +func serveExplorerTxJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerTxJS)) +} + +func serveExplorerNodeJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerNodeJS)) +} + +func serveExplorerRelaysPage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerRelaysHTML)) +} + +func serveExplorerRelaysJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerRelaysJS)) +} + +func serveExplorerValidatorsPage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerValidatorsHTML)) +} + +func serveExplorerValidatorsJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerValidatorsJS)) +} + +func serveExplorerContractPage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerContractHTML)) +} + +func serveExplorerContractJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerContractJS)) +} + +func serveExplorerTokensPage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerTokensHTML)) +} + +func serveExplorerTokensJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerTokensJS)) +} + +func serveExplorerTokenPage(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(explorerTokenHTML)) +} + +func serveExplorerTokenJS(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + _, _ = w.Write([]byte(explorerTokenJS)) +} diff --git a/node/metrics.go b/node/metrics.go new file mode 100644 index 0000000..b72883b --- /dev/null +++ b/node/metrics.go @@ -0,0 +1,232 @@ +// Package node — minimal Prometheus-format metrics. +// +// We expose counters, gauges, and histograms in the Prometheus text +// exposition format on GET /metrics. A full-blown prometheus/client_golang +// dependency would pull 10+ transitive modules; for our needs (a handful of +// metrics + stable output format) a ~200 LOC in-tree implementation is +// enough, with zero extra build surface. +// +// Concurrency: all metric types are safe for concurrent Inc/Add/Observe. +// Registration happens at init time and is not reentrant. +package node + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" +) + +// ─── registry ──────────────────────────────────────────────────────────────── + +// metric is the internal interface every exposed metric implements. +type metric interface { + // write emits the metric's lines to w in Prometheus text format. + write(w *strings.Builder) +} + +type metricRegistry struct { + mu sync.RWMutex + entries []metric +} + +var defaultRegistry = &metricRegistry{} + +func (r *metricRegistry) register(m metric) { + r.mu.Lock() + r.entries = append(r.entries, m) + r.mu.Unlock() +} + +// metricsHandler writes Prometheus exposition format for all registered metrics. +func metricsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var sb strings.Builder + defaultRegistry.mu.RLock() + for _, m := range defaultRegistry.entries { + m.write(&sb) + } + defaultRegistry.mu.RUnlock() + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + _, _ = w.Write([]byte(sb.String())) +} + +// ─── counter ───────────────────────────────────────────────────────────────── + +// MetricCounter is a monotonically increasing value. Typical use: number of +// blocks committed, number of rejected txs. +type MetricCounter struct { + name, help string + v atomic.Uint64 +} + +// NewCounter registers and returns a new counter. +func NewCounter(name, help string) *MetricCounter { + c := &MetricCounter{name: name, help: help} + defaultRegistry.register(c) + return c +} + +// Inc adds 1 to the counter. +func (c *MetricCounter) Inc() { c.v.Add(1) } + +// Add adds n to the counter (must be ≥ 0 — counters are monotonic). +func (c *MetricCounter) Add(n uint64) { c.v.Add(n) } + +func (c *MetricCounter) write(sb *strings.Builder) { + fmt.Fprintf(sb, "# HELP %s %s\n# TYPE %s counter\n%s %d\n", + c.name, c.help, c.name, c.name, c.v.Load()) +} + +// ─── gauge ─────────────────────────────────────────────────────────────────── + +// MetricGauge is a value that can go up or down. Typical use: current +// mempool size, active websocket connections, tip height. +type MetricGauge struct { + name, help string + v atomic.Int64 + fn func() int64 // optional live provider; if set, v is unused +} + +// NewGauge registers a gauge backed by an atomic Int64. +func NewGauge(name, help string) *MetricGauge { + g := &MetricGauge{name: name, help: help} + defaultRegistry.register(g) + return g +} + +// NewGaugeFunc registers a gauge whose value is fetched on scrape. Useful +// when the source of truth is some other subsystem (chain.TipIndex, peer +// count, etc.) and we don't want a separate mirror variable. +func NewGaugeFunc(name, help string, fn func() int64) *MetricGauge { + g := &MetricGauge{name: name, help: help, fn: fn} + defaultRegistry.register(g) + return g +} + +// Set overrides the stored value. Only meaningful for non-fn gauges. +func (g *MetricGauge) Set(v int64) { g.v.Store(v) } + +// Inc / Dec convenience for bookkeeping gauges. +func (g *MetricGauge) Inc() { g.v.Add(1) } +func (g *MetricGauge) Dec() { g.v.Add(-1) } + +func (g *MetricGauge) write(sb *strings.Builder) { + v := g.v.Load() + if g.fn != nil { + v = g.fn() + } + fmt.Fprintf(sb, "# HELP %s %s\n# TYPE %s gauge\n%s %d\n", + g.name, g.help, g.name, g.name, v) +} + +// ─── histogram ─────────────────────────────────────────────────────────────── + +// MetricHistogram is a fixed-bucket latency histogram. Typical use: time to +// commit a block, time to apply a tx. We use user-supplied buckets (upper +// bounds in seconds) and track sum + count alongside for Prometheus-standard +// output. +type MetricHistogram struct { + name, help string + buckets []float64 + counts []atomic.Uint64 + inf atomic.Uint64 + sum atomic.Uint64 // sum in microseconds to avoid floats + count atomic.Uint64 +} + +// NewHistogram registers a histogram with explicit bucket upper bounds (s). +// Buckets must be strictly increasing. The implicit +Inf bucket is added +// automatically per Prometheus spec. +func NewHistogram(name, help string, buckets []float64) *MetricHistogram { + sorted := make([]float64, len(buckets)) + copy(sorted, buckets) + sort.Float64s(sorted) + h := &MetricHistogram{ + name: name, help: help, + buckets: sorted, + counts: make([]atomic.Uint64, len(sorted)), + } + defaultRegistry.register(h) + return h +} + +// Observe records a single sample (duration in seconds). +func (h *MetricHistogram) Observe(seconds float64) { + // Record in every bucket whose upper bound ≥ sample. + for i, b := range h.buckets { + if seconds <= b { + h.counts[i].Add(1) + } + } + h.inf.Add(1) + h.sum.Add(uint64(seconds * 1_000_000)) // µs resolution + h.count.Add(1) +} + +func (h *MetricHistogram) write(sb *strings.Builder) { + fmt.Fprintf(sb, "# HELP %s %s\n# TYPE %s histogram\n", h.name, h.help, h.name) + for i, b := range h.buckets { + fmt.Fprintf(sb, `%s_bucket{le="%s"} %d`+"\n", + h.name, strconv.FormatFloat(b, 'g', -1, 64), h.counts[i].Load()) + } + fmt.Fprintf(sb, `%s_bucket{le="+Inf"} %d`+"\n", h.name, h.inf.Load()) + fmt.Fprintf(sb, "%s_sum %f\n", h.name, float64(h.sum.Load())/1_000_000) + fmt.Fprintf(sb, "%s_count %d\n", h.name, h.count.Load()) +} + +// ─── registered metrics (called from main.go) ──────────────────────────────── +// +// Keeping these as package-level vars lets callers just do +// `MetricBlocksTotal.Inc()` instead of threading a registry through every +// component. Names follow Prometheus naming conventions: +// ___ + +var ( + MetricBlocksTotal = NewCounter( + "dchain_blocks_total", + "Total number of blocks committed by this node", + ) + MetricTxsTotal = NewCounter( + "dchain_txs_total", + "Total number of transactions included in committed blocks", + ) + MetricTxSubmitAccepted = NewCounter( + "dchain_tx_submit_accepted_total", + "Transactions accepted into the mempool via /api/tx or WS submit_tx", + ) + MetricTxSubmitRejected = NewCounter( + "dchain_tx_submit_rejected_total", + "Transactions rejected at API validation (bad sig, timestamp, etc.)", + ) + MetricWSConnections = NewGauge( + "dchain_ws_connections", + "Currently open websocket connections on this node", + ) + MetricPeers = NewGauge( + "dchain_peer_count", + "Currently connected libp2p peers", + ) + // Block-commit latency — how long AddBlock takes end-to-end. Catches + // slow contract calls before they reach the freeze threshold. + MetricBlockCommitSeconds = NewHistogram( + "dchain_block_commit_seconds", + "Wall-clock seconds spent inside chain.AddBlock", + []float64{0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10, 30}, + ) + + // Worst validator liveness in the current set — how many seqNums have + // passed without a commit vote from the most-delinquent validator. + // Alert on this staying > 20 for more than a few minutes; that + // validator is either down, partitioned, or wedged. + MetricMaxMissedBlocks = NewGauge( + "dchain_max_missed_blocks", + "Highest missed-block count among current validators (0 if all healthy)", + ) +) diff --git a/node/sse.go b/node/sse.go new file mode 100644 index 0000000..67b63ed --- /dev/null +++ b/node/sse.go @@ -0,0 +1,186 @@ +// Package node — Server-Sent Events hub for the block explorer. +// +// Clients connect to GET /api/events and receive a real-time stream of: +// +// event: block — every committed block +// event: tx — every confirmed transaction (synthetic BLOCK_REWARD excluded) +// event: contract_log — every log entry written by a smart contract +// +// The stream uses the standard text/event-stream format so the browser's +// native EventSource API works without any library. +package node + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "go-blockchain/blockchain" +) + +// ── event payload types ────────────────────────────────────────────────────── + +// SSEBlockEvent is emitted when a block is committed. +type SSEBlockEvent struct { + Index uint64 `json:"index"` + Hash string `json:"hash"` + TxCount int `json:"tx_count"` + Validator string `json:"validator"` + Timestamp string `json:"timestamp"` +} + +// SSETxEvent is emitted for each confirmed transaction. +type SSETxEvent struct { + ID string `json:"id"` + TxType blockchain.EventType `json:"tx_type"` + From string `json:"from"` + To string `json:"to,omitempty"` + Amount uint64 `json:"amount,omitempty"` + Fee uint64 `json:"fee,omitempty"` +} + +// SSEContractLogEvent is emitted each time a contract calls env.log(). +type SSEContractLogEvent = blockchain.ContractLogEntry + +// ── hub ─────────────────────────────────────────────────────────────────────── + +// SSEHub manages all active SSE client connections. +// It is safe for concurrent use from multiple goroutines. +type SSEHub struct { + mu sync.RWMutex + clients map[chan string]struct{} +} + +// NewSSEHub returns an initialised hub ready to accept connections. +func NewSSEHub() *SSEHub { + return &SSEHub{clients: make(map[chan string]struct{})} +} + +// Clients returns the current number of connected SSE clients. +func (h *SSEHub) Clients() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +func (h *SSEHub) subscribe() chan string { + ch := make(chan string, 64) // buffered: drop events for slow clients + h.mu.Lock() + h.clients[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *SSEHub) unsubscribe(ch chan string) { + h.mu.Lock() + delete(h.clients, ch) + h.mu.Unlock() + close(ch) +} + +// emit serialises data and broadcasts an SSE message to all subscribers. +func (h *SSEHub) emit(eventName string, data any) { + payload, err := json.Marshal(data) + if err != nil { + return + } + // SSE wire format: "event: \ndata: \n\n" + msg := fmt.Sprintf("event: %s\ndata: %s\n\n", eventName, payload) + h.mu.RLock() + for ch := range h.clients { + select { + case ch <- msg: + default: // drop silently — client is too slow + } + } + h.mu.RUnlock() +} + +// ── public emit methods ─────────────────────────────────────────────────────── + +// EmitBlock broadcasts a "block" event for the committed block b. +func (h *SSEHub) EmitBlock(b *blockchain.Block) { + h.emit("block", SSEBlockEvent{ + Index: b.Index, + Hash: b.HashHex(), + TxCount: len(b.Transactions), + Validator: b.Validator, + Timestamp: b.Timestamp.UTC().Format(time.RFC3339), + }) +} + +// EmitTx broadcasts a "tx" event for each confirmed transaction. +// Synthetic BLOCK_REWARD records are skipped. +func (h *SSEHub) EmitTx(tx *blockchain.Transaction) { + if tx.Type == blockchain.EventBlockReward { + return + } + h.emit("tx", SSETxEvent{ + ID: tx.ID, + TxType: tx.Type, + From: tx.From, + To: tx.To, + Amount: tx.Amount, + Fee: tx.Fee, + }) +} + +// EmitContractLog broadcasts a "contract_log" event. +func (h *SSEHub) EmitContractLog(entry blockchain.ContractLogEntry) { + h.emit("contract_log", entry) +} + +// EmitBlockWithTxs calls EmitBlock then EmitTx for each transaction in b. +func (h *SSEHub) EmitBlockWithTxs(b *blockchain.Block) { + h.EmitBlock(b) + for _, tx := range b.Transactions { + h.EmitTx(tx) + } +} + +// ── HTTP handler ────────────────────────────────────────────────────────────── + +// ServeHTTP handles GET /api/events. +// The response is a text/event-stream that streams block, tx, and +// contract_log events until the client disconnects. +func (h *SSEHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported by this server", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // tell nginx not to buffer + + ch := h.subscribe() + defer h.unsubscribe(ch) + + // Opening comment — confirms the connection to the client. + fmt.Fprintf(w, ": connected to DChain event stream\n\n") + flusher.Flush() + + // Keepalive comments every 20 s prevent proxy/load-balancer timeouts. + keepalive := time.NewTicker(20 * time.Second) + defer keepalive.Stop() + + for { + select { + case msg, ok := <-ch: + if !ok { + return + } + fmt.Fprint(w, msg) + flusher.Flush() + case <-keepalive.C: + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} diff --git a/node/stats.go b/node/stats.go new file mode 100644 index 0000000..b0d29b7 --- /dev/null +++ b/node/stats.go @@ -0,0 +1,316 @@ +// Package node provides runtime statistics tracking for a validator node. +// Stats are accumulated with atomic counters (lock-free hot path) and +// exposed as a JSON HTTP endpoint on /stats and per-peer on /stats/peers. +package node + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/libp2p/go-libp2p/core/peer" + + "go-blockchain/blockchain" + "go-blockchain/economy" + "go-blockchain/wallet" +) + +// Tracker accumulates statistics for the running node. +// All counter fields are accessed atomically — safe from any goroutine. +type Tracker struct { + startTime time.Time + + // Consensus counters + BlocksProposed atomic.Int64 + BlocksCommitted atomic.Int64 + ViewChanges atomic.Int64 + VotesCast atomic.Int64 // PREPARE + COMMIT votes sent + + // Network counters + ConsensusMsgsSent atomic.Int64 + ConsensusMsgsRecv atomic.Int64 + BlocksGossipSent atomic.Int64 + BlocksGossipRecv atomic.Int64 + TxsGossipSent atomic.Int64 + TxsGossipRecv atomic.Int64 + BlocksSynced atomic.Int64 // downloaded via sync protocol + + // Per-peer routing stats + peersMu sync.RWMutex + peers map[peer.ID]*PeerStats +} + +// PeerStats tracks per-peer message counters. +type PeerStats struct { + PeerID peer.ID `json:"peer_id"` + ConnectedAt time.Time `json:"connected_at"` + MsgsSent atomic.Int64 + MsgsRecv atomic.Int64 + BlocksSent atomic.Int64 + BlocksRecv atomic.Int64 + SyncRequests atomic.Int64 // times we synced blocks from this peer +} + +// NewTracker creates a new Tracker with start time set to now. +func NewTracker() *Tracker { + return &Tracker{ + startTime: time.Now(), + peers: make(map[peer.ID]*PeerStats), + } +} + +// PeerConnected registers a new peer connection. +func (t *Tracker) PeerConnected(id peer.ID) { + t.peersMu.Lock() + defer t.peersMu.Unlock() + if _, ok := t.peers[id]; !ok { + t.peers[id] = &PeerStats{PeerID: id, ConnectedAt: time.Now()} + } +} + +// PeerDisconnected removes a peer (keeps the slot so history is visible). +func (t *Tracker) PeerDisconnected(id peer.ID) { + // intentionally kept — routing history is useful even after disconnect +} + +// peer returns (or lazily creates) the PeerStats for id. +func (t *Tracker) peer(id peer.ID) *PeerStats { + t.peersMu.RLock() + ps, ok := t.peers[id] + t.peersMu.RUnlock() + if ok { + return ps + } + t.peersMu.Lock() + defer t.peersMu.Unlock() + if ps, ok = t.peers[id]; ok { + return ps + } + ps = &PeerStats{PeerID: id, ConnectedAt: time.Now()} + t.peers[id] = ps + return ps +} + +// RecordConsensusSent increments consensus message sent counters for a peer. +func (t *Tracker) RecordConsensusSent(to peer.ID) { + t.ConsensusMsgsSent.Add(1) + t.peer(to).MsgsSent.Add(1) +} + +// RecordConsensusRecv increments consensus message received counters. +func (t *Tracker) RecordConsensusRecv(from peer.ID) { + t.ConsensusMsgsRecv.Add(1) + t.peer(from).MsgsRecv.Add(1) +} + +// RecordBlockGossipSent records a block broadcast to a peer. +func (t *Tracker) RecordBlockGossipSent() { + t.BlocksGossipSent.Add(1) +} + +// RecordBlockGossipRecv records receiving a block via gossip. +func (t *Tracker) RecordBlockGossipRecv(from peer.ID) { + t.BlocksGossipRecv.Add(1) + t.peer(from).BlocksRecv.Add(1) +} + +// RecordSyncFrom records blocks downloaded from a peer via the sync protocol. +func (t *Tracker) RecordSyncFrom(from peer.ID, count int) { + t.BlocksSynced.Add(int64(count)) + t.peer(from).SyncRequests.Add(1) +} + +// UptimeSeconds returns seconds since the tracker was created. +func (t *Tracker) UptimeSeconds() int64 { + return int64(time.Since(t.startTime).Seconds()) +} + +// --- HTTP API --- + +// StatsResponse is the full JSON payload returned by /stats. +type StatsResponse struct { + Node NodeInfo `json:"node"` + Chain ChainInfo `json:"chain"` + Consensus ConsensusInfo `json:"consensus"` + Network NetworkInfo `json:"network"` + Economy EconomyInfo `json:"economy"` + Reputation RepInfo `json:"reputation"` + Peers []PeerInfo `json:"peers"` +} + +type NodeInfo struct { + PubKey string `json:"pub_key"` + Address string `json:"address"` + PeerID string `json:"peer_id"` + UptimeSecs int64 `json:"uptime_secs"` + WalletBinding string `json:"wallet_binding,omitempty"` // DC address if bound +} + +type ChainInfo struct { + Height uint64 `json:"height"` + TipHash string `json:"tip_hash"` + TipTime string `json:"tip_time,omitempty"` +} + +type ConsensusInfo struct { + BlocksProposed int64 `json:"blocks_proposed"` + BlocksCommitted int64 `json:"blocks_committed"` + ViewChanges int64 `json:"view_changes"` + VotesCast int64 `json:"votes_cast"` +} + +type NetworkInfo struct { + PeersConnected int `json:"peers_connected"` + ConsensusMsgsSent int64 `json:"consensus_msgs_sent"` + ConsensusMsgsRecv int64 `json:"consensus_msgs_recv"` + BlocksGossipSent int64 `json:"blocks_gossip_sent"` + BlocksGossipRecv int64 `json:"blocks_gossip_recv"` + TxsGossipSent int64 `json:"txs_gossip_sent"` + TxsGossipRecv int64 `json:"txs_gossip_recv"` + BlocksSynced int64 `json:"blocks_synced"` +} + +type EconomyInfo struct { + BalanceMicroT uint64 `json:"balance_ut"` + BalanceDisplay string `json:"balance"` + WalletBalanceMicroT uint64 `json:"wallet_balance_ut,omitempty"` + WalletBalance string `json:"wallet_balance,omitempty"` +} + +type RepInfo struct { + Score int64 `json:"score"` + BlocksProduced uint64 `json:"blocks_produced"` + RelayProofs uint64 `json:"relay_proofs"` + SlashCount uint64 `json:"slash_count"` + Heartbeats uint64 `json:"heartbeats"` + Rank string `json:"rank"` +} + +type PeerInfo struct { + PeerID string `json:"peer_id"` + ConnectedAt string `json:"connected_at"` + MsgsSent int64 `json:"msgs_sent"` + MsgsRecv int64 `json:"msgs_recv"` + BlocksSent int64 `json:"blocks_sent"` + BlocksRecv int64 `json:"blocks_recv"` + SyncRequests int64 `json:"sync_requests"` +} + +// QueryFunc is called by the HTTP handler to fetch live chain/wallet state. +type QueryFunc struct { + PubKey func() string + PeerID func() string + PeersCount func() int + ChainTip func() *blockchain.Block + Balance func(pubKey string) uint64 + WalletBinding func(pubKey string) string // returns wallet DC address or "" + Reputation func(pubKey string) blockchain.RepStats +} + +// ServeHTTP returns a mux with /stats and /health, plus any extra routes registered via fns. +func (t *Tracker) ServeHTTP(q QueryFunc, fns ...func(*http.ServeMux)) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/stats", func(w http.ResponseWriter, r *http.Request) { + pubKey := q.PubKey() + tip := q.ChainTip() + + var height uint64 + var tipHash, tipTime string + if tip != nil { + height = tip.Index + tipHash = tip.HashHex() + tipTime = tip.Timestamp.Format(time.RFC3339) + } + + bal := q.Balance(pubKey) + rep := q.Reputation(pubKey) + walletBinding := q.WalletBinding(pubKey) + + t.peersMu.RLock() + var peerInfos []PeerInfo + for _, ps := range t.peers { + peerInfos = append(peerInfos, PeerInfo{ + PeerID: ps.PeerID.String(), + ConnectedAt: ps.ConnectedAt.Format(time.RFC3339), + MsgsSent: ps.MsgsSent.Load(), + MsgsRecv: ps.MsgsRecv.Load(), + BlocksSent: ps.BlocksSent.Load(), + BlocksRecv: ps.BlocksRecv.Load(), + SyncRequests: ps.SyncRequests.Load(), + }) + } + t.peersMu.RUnlock() + + resp := StatsResponse{ + Node: NodeInfo{ + PubKey: pubKey, + Address: wallet.PubKeyToAddress(pubKey), + PeerID: q.PeerID(), + UptimeSecs: t.UptimeSeconds(), + WalletBinding: walletBinding, + }, + Chain: ChainInfo{ + Height: height, + TipHash: tipHash, + TipTime: tipTime, + }, + Consensus: ConsensusInfo{ + BlocksProposed: t.BlocksProposed.Load(), + BlocksCommitted: t.BlocksCommitted.Load(), + ViewChanges: t.ViewChanges.Load(), + VotesCast: t.VotesCast.Load(), + }, + Network: NetworkInfo{ + PeersConnected: q.PeersCount(), + ConsensusMsgsSent: t.ConsensusMsgsSent.Load(), + ConsensusMsgsRecv: t.ConsensusMsgsRecv.Load(), + BlocksGossipSent: t.BlocksGossipSent.Load(), + BlocksGossipRecv: t.BlocksGossipRecv.Load(), + TxsGossipSent: t.TxsGossipSent.Load(), + TxsGossipRecv: t.TxsGossipRecv.Load(), + BlocksSynced: t.BlocksSynced.Load(), + }, + Economy: EconomyInfo{ + BalanceMicroT: bal, + BalanceDisplay: economy.FormatTokens(bal), + }, + Reputation: RepInfo{ + Score: rep.Score, + BlocksProduced: rep.BlocksProduced, + RelayProofs: rep.RelayProofs, + SlashCount: rep.SlashCount, + Heartbeats: rep.Heartbeats, + Rank: rep.Rank(), + }, + Peers: peerInfos, + } + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(resp); err != nil { + http.Error(w, `{"error":"failed to encode response"}`, http.StatusInternalServerError) + } + }) + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"status":"ok","uptime_secs":%d}`, t.UptimeSeconds()) + }) + + for _, fn := range fns { + fn(mux) + } + + return mux +} + +// ListenAndServe starts the HTTP stats server on addr (e.g. ":8080"). +func (t *Tracker) ListenAndServe(addr string, q QueryFunc, fns ...func(*http.ServeMux)) error { + handler := t.ServeHTTP(q, fns...) + return http.ListenAndServe(addr, handler) +} diff --git a/node/swagger/index.html b/node/swagger/index.html new file mode 100644 index 0000000..1fa0012 --- /dev/null +++ b/node/swagger/index.html @@ -0,0 +1,23 @@ + + + + + + DChain API Docs + + + +
+ + + + + diff --git a/node/swagger/openapi.json b/node/swagger/openapi.json new file mode 100644 index 0000000..426fbc3 --- /dev/null +++ b/node/swagger/openapi.json @@ -0,0 +1,727 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "DChain Node API", + "version": "0.4.0", + "description": "API for reading blockchain state, submitting transactions, and using the relay mailbox. All token amounts are in micro-tokens (µT). 1 T = 1 000 000 µT." + }, + "servers": [{ "url": "/" }], + "tags": [ + { "name": "chain", "description": "V2 chain-compatible endpoints" }, + { "name": "explorer", "description": "Block explorer read API" }, + { "name": "relay", "description": "Encrypted relay mailbox" } + ], + "paths": { + "/api/netstats": { + "get": { + "tags": ["explorer"], + "summary": "Network statistics", + "responses": { + "200": { + "description": "Aggregate chain stats", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NetStats" } } } + } + } + } + }, + "/api/blocks": { + "get": { + "tags": ["explorer"], + "summary": "Recent blocks", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } } + ], + "responses": { + "200": { + "description": "Array of recent block summaries", + "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/BlockSummary" } } } } + } + } + } + }, + "/api/block/{index}": { + "get": { + "tags": ["explorer"], + "summary": "Block by index", + "parameters": [ + { "name": "index", "in": "path", "required": true, "schema": { "type": "integer", "format": "uint64" } } + ], + "responses": { + "200": { "description": "Block detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BlockDetail" } } } }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/api/txs/recent": { + "get": { + "tags": ["explorer"], + "summary": "Recent transactions", + "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "maximum": 100 } } + ], + "responses": { + "200": { + "description": "Recent transactions", + "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TxListEntry" } } } } + } + } + } + }, + "/api/tx/{txid}": { + "get": { + "tags": ["explorer"], + "summary": "Transaction by ID", + "parameters": [ + { "name": "txid", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Transaction detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxDetail" } } } }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/api/tx": { + "post": { + "tags": ["explorer"], + "summary": "Submit a signed transaction", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Transaction" } } } + }, + "responses": { + "200": { "description": "Accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubmitTransactionResponse" } } } }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/api/address/{addr}": { + "get": { + "tags": ["explorer"], + "summary": "Address balance and transactions", + "description": "Accepts a DC... wallet address or hex Ed25519 public key.", + "parameters": [ + { "name": "addr", "in": "path", "required": true, "schema": { "type": "string" }, "description": "DC... address or hex pub key" }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } }, + { "name": "offset", "in": "query", "schema": { "type": "integer", "default": 0 } } + ], + "responses": { + "200": { "description": "Address detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddressDetail" } } } }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/api/node/{pubkey}": { + "get": { + "tags": ["explorer"], + "summary": "Node reputation and stats", + "parameters": [ + { "name": "pubkey", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Hex Ed25519 pub key or DC... address" }, + { "name": "window", "in": "query", "schema": { "type": "integer", "default": 200 }, "description": "Number of recent blocks to scan for rewards" } + ], + "responses": { + "200": { "description": "Node stats", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NodeStats" } } } }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/api/relays": { + "get": { + "tags": ["explorer"], + "summary": "Registered relay nodes", + "description": "Returns all nodes that have submitted an REGISTER_RELAY transaction.", + "responses": { + "200": { + "description": "List of relay nodes", + "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/RegisteredRelayInfo" } } } } + } + } + } + }, + "/api/validators": { + "get": { + "tags": ["explorer"], + "summary": "Active validator set", + "description": "Returns the current on-chain validator set. The set changes via ADD_VALIDATOR / REMOVE_VALIDATOR transactions.", + "responses": { + "200": { + "description": "Active validators", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { "type": "integer" }, + "validators": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pub_key": { "type": "string" }, + "address": { "type": "string" } + } + } + } + } + } + } + } + } + } + } + }, + "/api/identity/{pubkey}": { + "get": { + "tags": ["explorer"], + "summary": "Identity info (Ed25519 + X25519 keys)", + "description": "Returns identity info for a pub key or DC address. x25519_pub is populated only if the identity has submitted a REGISTER_KEY transaction with an X25519 key.", + "parameters": [ + { "name": "pubkey", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Hex Ed25519 pub key or DC... address" } + ], + "responses": { + "200": { "description": "Identity info", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IdentityInfo" } } } }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/relay/send": { + "post": { + "tags": ["relay"], + "summary": "Send an encrypted message via the relay node", + "description": "The relay node seals the message using its own X25519 keypair (sender = relay node) and broadcasts it on gossipsub. No on-chain fee is attached.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["recipient_pub", "msg_b64"], + "properties": { + "recipient_pub": { "type": "string", "description": "Hex X25519 public key of the recipient" }, + "msg_b64": { "type": "string", "description": "Base64-encoded plaintext message" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Message sent", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Envelope ID" }, + "recipient_pub": { "type": "string" }, + "status": { "type": "string", "example": "sent" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "503": { "$ref": "#/components/responses/Error" } + } + } + }, + "/relay/broadcast": { + "post": { + "tags": ["relay"], + "summary": "Broadcast a pre-sealed envelope", + "description": "Light clients that seal their own NaCl-box envelopes use this to publish without a direct libp2p connection. The node stores it in the mailbox and gossips it.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["envelope"], + "properties": { + "envelope": { "$ref": "#/components/schemas/Envelope" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Broadcast accepted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "status": { "type": "string", "example": "broadcast" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "503": { "$ref": "#/components/responses/Error" } + } + } + }, + "/relay/inbox": { + "get": { + "tags": ["relay"], + "summary": "List inbox envelopes", + "description": "Returns envelopes stored for the given X25519 public key. Envelopes remain encrypted — the relay cannot read them.", + "parameters": [ + { "name": "pub", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Hex X25519 public key of the recipient" }, + { "name": "since", "in": "query", "schema": { "type": "integer", "format": "int64" }, "description": "Unix timestamp — return only messages after this time" }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 50 } } + ], + "responses": { + "200": { + "description": "Inbox contents", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pub": { "type": "string" }, + "count": { "type": "integer" }, + "has_more": { "type": "boolean" }, + "items": { "type": "array", "items": { "$ref": "#/components/schemas/InboxItem" } } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" } + } + } + }, + "/relay/inbox/count": { + "get": { + "tags": ["relay"], + "summary": "Count inbox envelopes", + "parameters": [ + { "name": "pub", "in": "query", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "Envelope count", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pub": { "type": "string" }, + "count": { "type": "integer" } + } + } + } + } + } + } + } + }, + "/relay/inbox/{envID}": { + "delete": { + "tags": ["relay"], + "summary": "Delete an envelope from the inbox", + "parameters": [ + { "name": "envID", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "pub", "in": "query", "required": true, "schema": { "type": "string" }, "description": "X25519 pub key of the inbox owner" } + ], + "responses": { + "200": { + "description": "Deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "status": { "type": "string", "example": "deleted" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" } + } + } + }, + "/relay/contacts": { + "get": { + "tags": ["relay"], + "summary": "Incoming contact requests", + "description": "Returns all CONTACT_REQUEST records for the given Ed25519 pub key, including pending, accepted, and blocked.", + "parameters": [ + { "name": "pub", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Hex Ed25519 pub key" } + ], + "responses": { + "200": { + "description": "Contact list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pub": { "type": "string" }, + "count": { "type": "integer" }, + "contacts": { "type": "array", "items": { "$ref": "#/components/schemas/ContactInfo" } } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" } + } + } + }, + "/v2/chain/accounts/{account_id}/transactions": { + "get": { + "tags": ["chain"], + "summary": "Account transactions", + "parameters": [ + { "name": "account_id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "limit", "in": "query", "schema": { "type": "integer", "default": 100, "minimum": 1, "maximum": 1000 } }, + { "name": "order", "in": "query", "schema": { "type": "string", "enum": ["desc", "asc"], "default": "desc" } }, + { "name": "after_block", "in": "query", "schema": { "type": "integer", "format": "uint64" } }, + { "name": "before_block", "in": "query", "schema": { "type": "integer", "format": "uint64" } } + ], + "responses": { + "200": { "description": "Transactions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChainTransactionsResponse" } } } }, + "400": { "$ref": "#/components/responses/Error" }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/v2/chain/transactions/{tx_id}": { + "get": { + "tags": ["chain"], + "summary": "Transaction by ID", + "parameters": [ + { "name": "tx_id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Transaction detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChainTransactionDetailResponse" } } } }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/v2/chain/transactions": { + "post": { + "tags": ["chain"], + "summary": "Submit transaction", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubmitTransactionRequest" } } } + }, + "responses": { + "200": { "description": "Accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubmitTransactionResponse" } } } }, + "400": { "$ref": "#/components/responses/Error" }, + "500": { "$ref": "#/components/responses/Error" } + } + } + }, + "/v2/chain/transactions/draft": { + "post": { + "tags": ["chain"], + "summary": "Build unsigned TRANSFER draft", + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DraftTransactionRequest" } } } + }, + "responses": { + "200": { "description": "Draft", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DraftTransactionResponse" } } } }, + "400": { "$ref": "#/components/responses/Error" } + } + } + } + }, + "components": { + "responses": { + "Error": { + "description": "Error response", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } + } + }, + "schemas": { + "ErrorResponse": { + "type": "object", + "properties": { "error": { "type": "string" } }, + "required": ["error"] + }, + "NetStats": { + "type": "object", + "properties": { + "height": { "type": "integer" }, + "total_txs": { "type": "integer" }, + "total_transfers": { "type": "integer" }, + "total_relay_proofs": { "type": "integer" }, + "avg_block_time_ms": { "type": "number" }, + "tps": { "type": "number" }, + "total_supply_ut": { "type": "integer", "format": "uint64" } + } + }, + "BlockSummary": { + "type": "object", + "properties": { + "index": { "type": "integer", "format": "uint64" }, + "hash": { "type": "string" }, + "time": { "type": "string", "format": "date-time" }, + "validator": { "type": "string", "description": "DC... address" }, + "tx_count": { "type": "integer" }, + "total_fees_ut": { "type": "integer", "format": "uint64" } + } + }, + "BlockDetail": { + "allOf": [ + { "$ref": "#/components/schemas/BlockSummary" }, + { + "type": "object", + "properties": { + "prev_hash": { "type": "string" }, + "validator_addr": { "type": "string" }, + "transactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "type": "string" }, + "from": { "type": "string" }, + "to": { "type": "string" }, + "amount_ut": { "type": "integer", "format": "uint64" }, + "fee_ut": { "type": "integer", "format": "uint64" } + } + } + } + } + } + ] + }, + "TxListEntry": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "type": "string", "enum": ["TRANSFER","REGISTER_KEY","RELAY_PROOF","REGISTER_RELAY","CONTACT_REQUEST","ACCEPT_CONTACT","BLOCK_CONTACT","ADD_VALIDATOR","REMOVE_VALIDATOR","HEARTBEAT","BIND_WALLET","SLASH","OPEN_PAY_CHAN","CLOSE_PAY_CHAN","BLOCK_REWARD"] }, + "from": { "type": "string" }, + "from_addr": { "type": "string" }, + "to": { "type": "string" }, + "to_addr": { "type": "string" }, + "amount_ut": { "type": "integer", "format": "uint64" }, + "amount": { "type": "string", "description": "Human-readable token amount" }, + "fee_ut": { "type": "integer", "format": "uint64" }, + "fee": { "type": "string" }, + "time": { "type": "string", "format": "date-time" }, + "block_index": { "type": "integer", "format": "uint64" }, + "block_hash": { "type": "string" } + } + }, + "TxDetail": { + "allOf": [ + { "$ref": "#/components/schemas/TxListEntry" }, + { + "type": "object", + "properties": { + "block_time": { "type": "string", "format": "date-time" }, + "payload": { "description": "Decoded payload JSON (type-specific)" }, + "payload_hex": { "type": "string" }, + "signature_hex": { "type": "string" } + } + } + ] + }, + "AddressDetail": { + "type": "object", + "properties": { + "address": { "type": "string" }, + "pub_key": { "type": "string" }, + "balance_ut": { "type": "integer", "format": "uint64" }, + "balance": { "type": "string" }, + "tx_count": { "type": "integer" }, + "offset": { "type": "integer" }, + "limit": { "type": "integer" }, + "has_more": { "type": "boolean" }, + "next_offset": { "type": "integer" }, + "transactions": { "type": "array", "items": { "$ref": "#/components/schemas/TxListEntry" } } + } + }, + "NodeStats": { + "type": "object", + "properties": { + "pub_key": { "type": "string" }, + "address": { "type": "string" }, + "node_balance_ut": { "type": "integer", "format": "uint64" }, + "node_balance": { "type": "string" }, + "wallet_binding_address": { "type": "string" }, + "wallet_binding_balance_ut": { "type": "integer", "format": "uint64" }, + "reputation_score": { "type": "integer", "format": "int64" }, + "reputation_rank": { "type": "string", "enum": ["Observer","Active","Trusted","Validator"] }, + "blocks_produced": { "type": "integer", "format": "uint64" }, + "relay_proofs": { "type": "integer", "format": "uint64" }, + "slash_count": { "type": "integer", "format": "uint64" }, + "heartbeats": { "type": "integer", "format": "uint64" }, + "recent_window_blocks": { "type": "integer" }, + "recent_blocks_produced": { "type": "integer" }, + "recent_rewards_ut": { "type": "integer", "format": "uint64" }, + "recent_rewards": { "type": "string" } + } + }, + "IdentityInfo": { + "type": "object", + "properties": { + "pub_key": { "type": "string", "description": "Hex Ed25519 public key" }, + "address": { "type": "string", "description": "DC... wallet address" }, + "x25519_pub": { "type": "string", "description": "Hex Curve25519 public key for E2E encryption; empty if not published" }, + "nickname": { "type": "string" }, + "registered": { "type": "boolean", "description": "true if REGISTER_KEY tx was committed" } + } + }, + "RegisteredRelayInfo": { + "type": "object", + "properties": { + "pub_key": { "type": "string" }, + "address": { "type": "string" }, + "relay": { + "type": "object", + "properties": { + "x25519_pub_key": { "type": "string" }, + "fee_per_msg_ut": { "type": "integer", "format": "uint64" }, + "multiaddr": { "type": "string" } + } + } + } + }, + "Envelope": { + "type": "object", + "description": "NaCl-box sealed message envelope. Only the holder of the recipient's X25519 private key can decrypt it.", + "required": ["id", "recipient_pub", "sender_pub", "nonce", "ciphertext"], + "properties": { + "id": { "type": "string", "description": "Hex SHA-256[:16] of nonce||ciphertext" }, + "recipient_pub": { "type": "string", "description": "Hex X25519 public key of the recipient" }, + "sender_pub": { "type": "string", "description": "Hex X25519 public key of the sender" }, + "sender_ed25519_pub": { "type": "string", "description": "Sender's Ed25519 pub key for on-chain fee claims" }, + "fee_ut": { "type": "integer", "format": "uint64", "description": "Delivery fee µT (0 = free)" }, + "fee_sig": { "type": "string", "format": "byte", "description": "Ed25519 sig over FeeAuthBytes(id, fee_ut)" }, + "nonce": { "type": "string", "format": "byte", "description": "24-byte NaCl nonce (base64)" }, + "ciphertext": { "type": "string", "format": "byte", "description": "NaCl box ciphertext (base64)" }, + "sent_at": { "type": "integer", "format": "int64", "description": "Unix timestamp" } + } + }, + "InboxItem": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "sender_pub": { "type": "string" }, + "recipient_pub": { "type": "string" }, + "fee_ut": { "type": "integer", "format": "uint64" }, + "sent_at": { "type": "integer", "format": "int64" }, + "sent_at_human": { "type": "string", "format": "date-time" }, + "nonce": { "type": "string", "format": "byte" }, + "ciphertext": { "type": "string", "format": "byte" } + } + }, + "ContactInfo": { + "type": "object", + "properties": { + "requester_pub": { "type": "string" }, + "requester_addr": { "type": "string" }, + "status": { "type": "string", "enum": ["pending", "accepted", "blocked"] }, + "intro": { "type": "string", "description": "Optional plaintext intro (≤ 280 chars)" }, + "fee_ut": { "type": "integer", "format": "uint64" }, + "tx_id": { "type": "string" }, + "created_at": { "type": "integer", "format": "int64" } + } + }, + "Transaction": { + "type": "object", + "description": "Signed blockchain transaction. Sign the canonical JSON of the object with Signature set to null, using Ed25519.", + "required": ["type", "from"], + "properties": { + "id": { "type": "string" }, + "type": { "type": "string", "enum": ["TRANSFER","REGISTER_KEY","RELAY_PROOF","REGISTER_RELAY","CONTACT_REQUEST","ACCEPT_CONTACT","BLOCK_CONTACT","ADD_VALIDATOR","REMOVE_VALIDATOR","HEARTBEAT","BIND_WALLET","SLASH","OPEN_PAY_CHAN","CLOSE_PAY_CHAN"] }, + "from": { "type": "string", "description": "Hex Ed25519 pub key of the signer" }, + "to": { "type": "string", "description": "Hex Ed25519 pub key of the recipient (if applicable)" }, + "amount": { "type": "integer", "format": "uint64", "description": "µT to transfer (TRANSFER, CONTACT_REQUEST)" }, + "fee": { "type": "integer", "format": "uint64", "description": "µT fee to block validator (min 1000)" }, + "memo": { "type": "string" }, + "payload": { "type": "string", "format": "byte", "description": "Base64 JSON payload (type-specific)" }, + "signature": { "type": "string", "format": "byte", "description": "Ed25519 signature over canonical bytes" }, + "timestamp": { "type": "string", "format": "date-time" } + } + }, + "SubmitTransactionRequest": { + "type": "object", + "properties": { + "tx": { "$ref": "#/components/schemas/Transaction" }, + "signed_tx": { "type": "string", "description": "Signed transaction as JSON/base64/hex string" } + } + }, + "DraftTransactionRequest": { + "type": "object", + "required": ["from", "to", "amount_ut"], + "properties": { + "from": { "type": "string" }, + "to": { "type": "string" }, + "amount_ut": { "type": "integer", "format": "uint64" }, + "memo": { "type": "string" }, + "fee_ut": { "type": "integer", "format": "uint64", "description": "Optional; defaults to MinFee (1000 µT)" } + } + }, + "DraftTransactionResponse": { + "type": "object", + "properties": { + "tx": { "$ref": "#/components/schemas/Transaction" }, + "sign_bytes_hex": { "type": "string" }, + "sign_bytes_base64": { "type": "string" }, + "note": { "type": "string" } + }, + "required": ["tx", "sign_bytes_hex", "sign_bytes_base64"] + }, + "SubmitTransactionResponse": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "id": { "type": "string" } + }, + "required": ["status", "id"] + }, + "ChainTx": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "type": { "type": "string" }, + "memo": { "type": "string" }, + "from": { "type": "string" }, + "from_addr": { "type": "string" }, + "to": { "type": "string" }, + "to_addr": { "type": "string" }, + "amount_ut": { "type": "integer", "format": "uint64" }, + "fee_ut": { "type": "integer", "format": "uint64" }, + "block_index": { "type": "integer", "format": "uint64" }, + "block_hash": { "type": "string" }, + "time": { "type": "string", "format": "date-time" } + } + }, + "ChainTransactionsResponse": { + "type": "object", + "properties": { + "account_id": { "type": "string" }, + "account_addr": { "type": "string" }, + "count": { "type": "integer" }, + "order": { "type": "string" }, + "limit_applied": { "type": "integer" }, + "transactions": { "type": "array", "items": { "$ref": "#/components/schemas/ChainTx" } } + } + }, + "ChainTransactionDetailResponse": { + "type": "object", + "properties": { + "tx": { "$ref": "#/components/schemas/ChainTx" }, + "payload": {}, + "payload_hex": { "type": "string" }, + "signature_hex": { "type": "string" } + } + } + } + } +} diff --git a/node/swagger_assets.go b/node/swagger_assets.go new file mode 100644 index 0000000..005a6a6 --- /dev/null +++ b/node/swagger_assets.go @@ -0,0 +1,32 @@ +package node + +import ( + _ "embed" + "net/http" +) + +//go:embed swagger/openapi.json +var swaggerOpenAPI string + +//go:embed swagger/index.html +var swaggerIndexHTML string + +func registerSwaggerRoutes(mux *http.ServeMux) { + mux.HandleFunc("/swagger/openapi.json", serveSwaggerOpenAPI) + mux.HandleFunc("/swagger", serveSwaggerUI) + mux.HandleFunc("/swagger/", serveSwaggerUI) +} + +func serveSwaggerOpenAPI(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(swaggerOpenAPI)) +} + +func serveSwaggerUI(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/swagger" && r.URL.Path != "/swagger/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(swaggerIndexHTML)) +} diff --git a/node/version/version.go b/node/version/version.go new file mode 100644 index 0000000..89ae0d2 --- /dev/null +++ b/node/version/version.go @@ -0,0 +1,70 @@ +// Package version carries build-time identity for the node binary. +// +// All four variables below are overridable at link time via -ldflags -X. The +// canonical build command is: +// +// VERSION_TAG=$(git describe --tags --always --dirty) +// VERSION_COMMIT=$(git rev-parse HEAD) +// VERSION_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) +// VERSION_DIRTY=$(git diff --quiet HEAD -- 2>/dev/null && echo false || echo true) +// +// go build -ldflags "\ +// -X go-blockchain/node/version.Tag=$VERSION_TAG \ +// -X go-blockchain/node/version.Commit=$VERSION_COMMIT \ +// -X go-blockchain/node/version.Date=$VERSION_DATE \ +// -X go-blockchain/node/version.Dirty=$VERSION_DIRTY" ./cmd/node +// +// Both Dockerfile and Dockerfile.slim run this at image build time (see ARG +// VERSION_* + RUN go build … -ldflags lines). A naked `go build ./...` +// without ldflags falls back to the zero defaults — that's fine for local +// dev, just not shipped to users. +// +// Protocol versions come from a different source: see node.ProtocolVersion, +// which is a compile-time const that only bumps on wire-protocol breaking +// changes. Protocol version can be the same across many build tags. +package version + +// Tag is a human-readable version label, typically `git describe --tags +// --always --dirty`. Examples: "v0.5.1", "v0.5.1-3-gabc1234", "abc1234-dirty". +// "dev" is the fallback when built without ldflags. +var Tag = "dev" + +// Commit is the full 40-char git SHA of the HEAD at build time. "none" when +// unset. Useful for precise bisect / release-channel lookups even when Tag +// is a non-unique label like "dev". +var Commit = "none" + +// Date is the build timestamp in RFC3339 (UTC). "unknown" when unset. +// Exposed in /api/well-known-version so operators can see how stale a +// deployed binary is without `docker exec`. +var Date = "unknown" + +// Dirty is the string "true" if the working tree had uncommitted changes at +// build time, "false" otherwise. Always a string (not bool) because ldflags +// can only inject strings — parse with == "true" at call sites. +var Dirty = "false" + +// String returns a one-line summary suitable for `node --version` output. +// Format: "dchain-node (commit= date= dirty=)". +func String() string { + short := Commit + if len(short) > 8 { + short = short[:8] + } + return "dchain-node " + Tag + " (commit=" + short + " date=" + Date + " dirty=" + Dirty + ")" +} + +// Info returns the version fields as a map, suitable for JSON embedding in +// /api/well-known-version and similar introspection endpoints. +func Info() map[string]string { + return map[string]string{ + "tag": Tag, + "commit": Commit, + "date": Date, + "dirty": Dirty, + } +} + +// IsDirty reports whether the build was from an unclean working tree. +// Convenience over parsing Dirty == "true" at call sites. +func IsDirty() bool { return Dirty == "true" } diff --git a/node/ws.go b/node/ws.go new file mode 100644 index 0000000..d8a680f --- /dev/null +++ b/node/ws.go @@ -0,0 +1,696 @@ +// Package node — WebSocket gateway. +// +// Clients connect to GET /api/ws and maintain a persistent bidirectional +// connection. The gateway eliminates HTTP polling for balance, messages, +// and contact requests by pushing events as soon as they are committed. +// +// Protocol (JSON, one frame per line): +// +// Client → Server: +// +// { "op": "subscribe", "topic": "tx" } +// { "op": "subscribe", "topic": "blocks" } +// { "op": "subscribe", "topic": "addr:" } // txs involving this address +// { "op": "unsubscribe", "topic": "..." } +// { "op": "ping" } +// +// Server → Client: +// +// { "event": "hello", "chain_id": "dchain-...", "tip_height": 1234 } +// { "event": "block", "data": { index, hash, tx_count, validator, timestamp } } +// { "event": "tx", "data": { id, tx_type, from, to, amount, fee } } +// { "event": "contract_log", "data": { ... } } +// { "event": "pong" } +// { "event": "error", "msg": "..." } +// { "event": "subscribed", "topic": "..." } +// +// Design notes: +// - Each connection has a bounded outbox (64 frames). If the client is +// slower than the producer, oldest frames are dropped and a +// `{"event":"lag"}` notice is sent so the UI can trigger a resync. +// - Subscriptions are per-connection and kept in memory only. Reconnection +// requires re-subscribing; this is cheap because the client's Zustand +// store remembers what it needs. +// - The hub reuses the same event sources as the SSE hub so both transports +// stay in sync; any caller that emits to SSE also emits here. +package node + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + "go-blockchain/blockchain" +) + +// ── wire types ─────────────────────────────────────────────────────────────── + +type wsClientCmd struct { + Op string `json:"op"` + Topic string `json:"topic,omitempty"` + // submit_tx fields — carries a full signed transaction + a client-assigned + // request id so the ack frame can be correlated on the client. + Tx json.RawMessage `json:"tx,omitempty"` + ID string `json:"id,omitempty"` + // auth fields — client proves ownership of an Ed25519 pubkey by signing + // the server-supplied nonce (sent in the hello frame as auth_nonce). + PubKey string `json:"pubkey,omitempty"` + Signature string `json:"sig,omitempty"` + // typing fields — `to` is the recipient's X25519 pubkey (same key used + // for the inbox topic). Server fans out to subscribers of + // `typing:` so the recipient's client can show an indicator. + // Purely ephemeral: never stored, never gossiped across nodes. + To string `json:"to,omitempty"` +} + +type wsServerFrame struct { + Event string `json:"event"` + Data json.RawMessage `json:"data,omitempty"` + Topic string `json:"topic,omitempty"` + Msg string `json:"msg,omitempty"` + ChainID string `json:"chain_id,omitempty"` + TipHeight uint64 `json:"tip_height,omitempty"` + // Sent with the hello frame. The client signs it with their Ed25519 + // private key and replies via the `auth` op; the server binds the + // connection to the authenticated pubkey for scoped subscriptions. + AuthNonce string `json:"auth_nonce,omitempty"` + // submit_ack fields. + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// WSSubmitTxHandler is the hook the hub calls on receiving a `submit_tx` op. +// Implementations should do the same validation as the HTTP /api/tx handler +// (timestamp window + signature verify + mempool add) and return an error on +// rejection. A nil error means the tx has been accepted into the mempool. +type WSSubmitTxHandler func(txJSON []byte) (txID string, err error) + +// ── hub ────────────────────────────────────────────────────────────────────── + +// X25519ForPubFn, if set, lets the hub map an authenticated Ed25519 pubkey +// to the X25519 key the same identity uses for relay encryption. This lets +// the auth'd client subscribe to their own inbox without revealing that +// mapping to unauthenticated clients. Returns ("", nil) if unknown. +type X25519ForPubFn func(ed25519PubHex string) (x25519PubHex string, err error) + +// WSHub tracks every open websocket connection and fans out events based on +// per-connection topic filters. +type WSHub struct { + mu sync.RWMutex + clients map[*wsClient]struct{} + upgrader websocket.Upgrader + chainID func() string + tip func() uint64 + // submitTx is the optional handler for `submit_tx` client ops. If nil, + // the hub replies with an error so clients know to fall back to HTTP. + submitTx WSSubmitTxHandler + // x25519For maps Ed25519 pubkey → X25519 pubkey (from the identity + // registry) so the hub can validate `inbox:` subscriptions + // against the authenticated identity. Optional — unset disables + // inbox auth (subscribe just requires auth but not identity lookup). + x25519For X25519ForPubFn + + // Per-IP connection counter. Guards against a single host opening + // unbounded sockets (memory exhaustion, descriptor exhaustion). + // Counter mutates on connect/disconnect; protected by perIPMu rather + // than the hub's main mu so fanout isn't blocked by bookkeeping. + perIPMu sync.Mutex + perIP map[string]int + maxPerIP int +} + +// WSMaxConnectionsPerIP caps concurrent websocket connections from one IP. +// Chosen to comfortably fit a power user with multiple devices (phone, web, +// load-test script) while still bounding a DoS. Override via SetMaxPerIP +// on the hub if a specific deployment needs different limits. +const WSMaxConnectionsPerIP = 10 + +// WSMaxSubsPerConnection caps subscriptions per single connection. A real +// client needs 3-5 topics (addr, inbox, blocks). 32 is generous headroom +// without letting one conn hold thousands of topic entries. +const WSMaxSubsPerConnection = 32 + +// NewWSHub constructs a hub. `chainID` and `tip` are optional snapshot +// functions used only for the hello frame. +func NewWSHub(chainID func() string, tip func() uint64) *WSHub { + return &WSHub{ + clients: make(map[*wsClient]struct{}), + upgrader: websocket.Upgrader{ + // The node may sit behind a reverse proxy; allow cross-origin + // upgrades. Rate limiting happens separately via api_guards. + CheckOrigin: func(r *http.Request) bool { return true }, + ReadBufferSize: 4 * 1024, + WriteBufferSize: 4 * 1024, + }, + chainID: chainID, + tip: tip, + perIP: make(map[string]int), + maxPerIP: WSMaxConnectionsPerIP, + } +} + +// SetMaxPerIP overrides the default per-IP connection cap. Must be called +// before Upgrade starts accepting — otherwise racy with new connections. +func (h *WSHub) SetMaxPerIP(n int) { + if n <= 0 { + return + } + h.perIPMu.Lock() + h.maxPerIP = n + h.perIPMu.Unlock() +} + +// SetSubmitTxHandler installs the handler for `submit_tx` ops. Pass nil to +// disable WS submission (clients will need to keep using HTTP POST /api/tx). +func (h *WSHub) SetSubmitTxHandler(fn WSSubmitTxHandler) { + h.submitTx = fn +} + +// SetX25519ForPub installs the Ed25519→X25519 lookup used to validate +// inbox subscriptions post-auth. Pass nil to disable the check. +func (h *WSHub) SetX25519ForPub(fn X25519ForPubFn) { + h.x25519For = fn +} + +// Clients returns the number of active websocket connections. +func (h *WSHub) Clients() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +// ServeHTTP handles GET /api/ws and upgrades to a websocket. +func (h *WSHub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + + // Access-token gating (if configured). Private nodes require the token + // for the upgrade itself. Public-with-token-for-writes nodes check it + // here too — tokenOK is stored on the client so submit_tx can rely on + // the upgrade-time decision without re-reading headers per op. + tokenOK := true + if tok, _ := AccessTokenForWS(); tok != "" { + if err := checkAccessToken(r); err != nil { + if _, private := AccessTokenForWS(); private { + w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`) + http.Error(w, "ws: "+err.Error(), http.StatusUnauthorized) + return + } + // Public-with-token mode: upgrade allowed but write ops gated. + tokenOK = false + } + } + + // Per-IP quota check. Reject BEFORE upgrade so we never hold an open + // socket for a client we're going to kick. 429 Too Many Requests is the + // closest status code — TCP is fine, they just opened too many. + h.perIPMu.Lock() + if h.perIP[ip] >= h.maxPerIP { + h.perIPMu.Unlock() + w.Header().Set("Retry-After", "30") + http.Error(w, fmt.Sprintf("too many websocket connections from %s (max %d)", ip, h.maxPerIP), http.StatusTooManyRequests) + return + } + h.perIP[ip]++ + h.perIPMu.Unlock() + + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + // Upgrade already wrote an HTTP error response. Release the reservation. + h.perIPMu.Lock() + h.perIP[ip]-- + if h.perIP[ip] <= 0 { + delete(h.perIP, ip) + } + h.perIPMu.Unlock() + return + } + // Generate a fresh 32-byte nonce per connection. Client signs this + // with its Ed25519 private key to prove identity; binding is per- + // connection (reconnect → new nonce → new auth). + nonceBytes := make([]byte, 32) + if _, err := rand.Read(nonceBytes); err != nil { + conn.Close() + return + } + nonce := hex.EncodeToString(nonceBytes) + + c := &wsClient{ + conn: conn, + send: make(chan []byte, 64), + subs: make(map[string]struct{}), + authNonce: nonce, + remoteIP: ip, + tokenOK: tokenOK, + } + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() + MetricWSConnections.Inc() + + // Send hello with chain metadata so the client can show connection state + // and verify it's on the expected chain. + var chainID string + var tip uint64 + if h.chainID != nil { + chainID = h.chainID() + } + if h.tip != nil { + tip = h.tip() + } + helloBytes, _ := json.Marshal(wsServerFrame{ + Event: "hello", + ChainID: chainID, + TipHeight: tip, + AuthNonce: nonce, + }) + select { + case c.send <- helloBytes: + default: + } + + // Reader + writer goroutines. When either returns, the other is signalled + // to shut down by closing the send channel. + go h.writeLoop(c) + h.readLoop(c) // blocks until the connection closes +} + +func (h *WSHub) removeClient(c *wsClient) { + h.mu.Lock() + wasRegistered := false + if _, ok := h.clients[c]; ok { + delete(h.clients, c) + close(c.send) + wasRegistered = true + } + h.mu.Unlock() + if wasRegistered { + MetricWSConnections.Dec() + } + // Release the per-IP reservation so the client can reconnect without + // being rejected. Missing-or-zero counters are silently no-op'd. + if c.remoteIP != "" { + h.perIPMu.Lock() + if h.perIP[c.remoteIP] > 0 { + h.perIP[c.remoteIP]-- + if h.perIP[c.remoteIP] == 0 { + delete(h.perIP, c.remoteIP) + } + } + h.perIPMu.Unlock() + } + _ = c.conn.Close() +} + +// readLoop processes control frames + parses JSON commands from the client. +func (h *WSHub) readLoop(c *wsClient) { + defer h.removeClient(c) + c.conn.SetReadLimit(16 * 1024) // reject oversized frames + _ = c.conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + c.conn.SetPongHandler(func(string) error { + _ = c.conn.SetReadDeadline(time.Now().Add(90 * time.Second)) + return nil + }) + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + return + } + var cmd wsClientCmd + if err := json.Unmarshal(data, &cmd); err != nil { + c.sendFrame(wsServerFrame{Event: "error", Msg: "invalid JSON"}) + continue + } + switch cmd.Op { + case "auth": + // Verify signature over the connection's auth_nonce. On success, + // bind this connection to the declared pubkey; scoped subs are + // limited to topics this identity owns. + pub, err := hex.DecodeString(cmd.PubKey) + if err != nil || len(pub) != ed25519.PublicKeySize { + c.sendFrame(wsServerFrame{Event: "error", Msg: "auth: invalid pubkey"}) + continue + } + sig, err := hex.DecodeString(cmd.Signature) + if err != nil || len(sig) != ed25519.SignatureSize { + c.sendFrame(wsServerFrame{Event: "error", Msg: "auth: invalid signature"}) + continue + } + if !ed25519.Verify(ed25519.PublicKey(pub), []byte(c.authNonce), sig) { + c.sendFrame(wsServerFrame{Event: "error", Msg: "auth: signature mismatch"}) + continue + } + var x25519 string + if h.x25519For != nil { + x25519, _ = h.x25519For(cmd.PubKey) // non-fatal on error + } + c.mu.Lock() + c.authPubKey = cmd.PubKey + c.authX25519 = x25519 + c.mu.Unlock() + c.sendFrame(wsServerFrame{Event: "subscribed", Topic: "auth:" + cmd.PubKey[:12] + "…"}) + case "subscribe": + topic := strings.TrimSpace(cmd.Topic) + if topic == "" { + c.sendFrame(wsServerFrame{Event: "error", Msg: "topic required"}) + continue + } + // Scoped topics require matching auth. Unauthenticated clients + // get global streams (`blocks`, `tx`, `contract_log`, …) only. + if err := h.authorizeSubscribe(c, topic); err != nil { + c.sendFrame(wsServerFrame{Event: "error", Msg: "forbidden: " + err.Error()}) + continue + } + c.mu.Lock() + if len(c.subs) >= WSMaxSubsPerConnection { + c.mu.Unlock() + c.sendFrame(wsServerFrame{Event: "error", Msg: fmt.Sprintf("subscription limit exceeded (%d)", WSMaxSubsPerConnection)}) + continue + } + c.subs[topic] = struct{}{} + c.mu.Unlock() + c.sendFrame(wsServerFrame{Event: "subscribed", Topic: topic}) + case "unsubscribe": + c.mu.Lock() + delete(c.subs, cmd.Topic) + c.mu.Unlock() + case "ping": + c.sendFrame(wsServerFrame{Event: "pong"}) + case "typing": + // Ephemeral signal: A is typing to B. Requires auth so the + // "from" claim is verifiable; anon clients can't spoof + // typing indicators for other identities. + c.mu.RLock() + fromX := c.authX25519 + c.mu.RUnlock() + to := strings.TrimSpace(cmd.To) + if fromX == "" || to == "" { + // Silently drop — no need to error, typing is best-effort. + continue + } + data, _ := json.Marshal(map[string]string{ + "from": fromX, + "to": to, + }) + h.fanout(wsServerFrame{Event: "typing", Data: data}, + []string{"typing:" + to}) + case "submit_tx": + // Low-latency transaction submission over the existing WS + // connection. Avoids the HTTP round-trip and delivers a + // submit_ack correlated by the client-supplied id so callers + // don't have to poll for inclusion status. + c.mu.RLock() + tokOK := c.tokenOK + c.mu.RUnlock() + if !tokOK { + c.sendFrame(wsServerFrame{ + Event: "submit_ack", + ID: cmd.ID, + Status: "rejected", + Reason: "submit requires access token; pass ?token= at /api/ws upgrade", + }) + continue + } + if h.submitTx == nil { + c.sendFrame(wsServerFrame{ + Event: "submit_ack", + ID: cmd.ID, + Status: "rejected", + Reason: "submit_tx over WS not available on this node", + }) + continue + } + if len(cmd.Tx) == 0 { + c.sendFrame(wsServerFrame{ + Event: "submit_ack", ID: cmd.ID, + Status: "rejected", Reason: "missing tx", + }) + continue + } + txID, err := h.submitTx(cmd.Tx) + if err != nil { + c.sendFrame(wsServerFrame{ + Event: "submit_ack", ID: cmd.ID, + Status: "rejected", Reason: err.Error(), + }) + continue + } + // Echo back the server-assigned tx id for confirmation. The + // client already knows it (it generated it), but this lets + // proxies / middleware log a proper request/response pair. + c.sendFrame(wsServerFrame{ + Event: "submit_ack", ID: cmd.ID, + Status: "accepted", Msg: txID, + }) + default: + c.sendFrame(wsServerFrame{Event: "error", Msg: "unknown op: " + cmd.Op}) + } + } +} + +// writeLoop pumps outbound frames and sends periodic pings. +func (h *WSHub) writeLoop(c *wsClient) { + ping := time.NewTicker(30 * time.Second) + defer ping.Stop() + for { + select { + case msg, ok := <-c.send: + if !ok { + return + } + _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ping.C: + _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// authorizeSubscribe gates which topics a connection is allowed to join. +// +// Rules: +// - Global topics (blocks, tx, contract_log, contract:*, inbox) are +// open to anyone — `tx` leaks only public envelope fields, and the +// whole-`inbox` firehose is encrypted per-recipient anyway. +// - Scoped topics addressed at a specific identity require the client +// to have authenticated as that identity: +// addr: — only the owning Ed25519 pubkey +// inbox: — only the identity whose registered X25519 +// key equals this (looked up via x25519For) +// +// Without this check any curl client could subscribe to any address and +// watch incoming transactions in real time — a significant metadata leak. +func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error { + // Open topics — always allowed. + switch topic { + case "blocks", "tx", "contract_log", "inbox", "$system": + return nil + } + if strings.HasPrefix(topic, "contract:") { + return nil // contract-wide log streams are public + } + + c.mu.RLock() + authed := c.authPubKey + authX := c.authX25519 + c.mu.RUnlock() + + if strings.HasPrefix(topic, "addr:") { + if authed == "" { + return fmt.Errorf("addr:* requires auth") + } + want := strings.TrimPrefix(topic, "addr:") + if want != authed { + return fmt.Errorf("addr:* only for your own pubkey") + } + return nil + } + if strings.HasPrefix(topic, "inbox:") { + if authed == "" { + return fmt.Errorf("inbox:* requires auth") + } + // If we have an x25519 mapping, enforce it; otherwise accept + // (best-effort — identity may not be registered yet). + if authX != "" { + want := strings.TrimPrefix(topic, "inbox:") + if want != authX { + return fmt.Errorf("inbox:* only for your own x25519") + } + } + return nil + } + if strings.HasPrefix(topic, "typing:") { + // Same rule as inbox: you can only listen for "who's typing to ME". + if authed == "" { + return fmt.Errorf("typing:* requires auth") + } + if authX != "" { + want := strings.TrimPrefix(topic, "typing:") + if want != authX { + return fmt.Errorf("typing:* only for your own x25519") + } + } + return nil + } + + // Unknown scoped form — default-deny. + return fmt.Errorf("topic %q not recognised", topic) +} + +// fanout sends frame to every client subscribed to any of the given topics. +func (h *WSHub) fanout(frame wsServerFrame, topics []string) { + buf, err := json.Marshal(frame) + if err != nil { + return + } + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.clients { + c.mu.RLock() + matched := false + for _, t := range topics { + if _, ok := c.subs[t]; ok { + matched = true + break + } + } + c.mu.RUnlock() + if !matched { + continue + } + select { + case c.send <- buf: + default: + // Outbox full — drop and notify once. + lagFrame, _ := json.Marshal(wsServerFrame{Event: "lag"}) + select { + case c.send <- lagFrame: + default: + } + } + } +} + +// ── public emit methods ─────────────────────────────────────────────────────── + +// EmitBlock notifies subscribers of the `blocks` topic. +func (h *WSHub) EmitBlock(b *blockchain.Block) { + data, _ := json.Marshal(SSEBlockEvent{ + Index: b.Index, + Hash: b.HashHex(), + TxCount: len(b.Transactions), + Validator: b.Validator, + Timestamp: b.Timestamp.UTC().Format(time.RFC3339), + }) + h.fanout(wsServerFrame{Event: "block", Data: data}, []string{"blocks"}) +} + +// EmitTx notifies: +// - `tx` topic (firehose) +// - `addr:` topic +// - `addr:` topic (if distinct from from) +// +// Synthetic BLOCK_REWARD transactions use `addr:` only (the validator). +func (h *WSHub) EmitTx(tx *blockchain.Transaction) { + data, _ := json.Marshal(SSETxEvent{ + ID: tx.ID, + TxType: tx.Type, + From: tx.From, + To: tx.To, + Amount: tx.Amount, + Fee: tx.Fee, + }) + topics := []string{"tx"} + if tx.From != "" { + topics = append(topics, "addr:"+tx.From) + } + if tx.To != "" && tx.To != tx.From { + topics = append(topics, "addr:"+tx.To) + } + h.fanout(wsServerFrame{Event: "tx", Data: data}, topics) +} + +// EmitContractLog fans out to the `contract_log` and `contract:` topics. +func (h *WSHub) EmitContractLog(entry blockchain.ContractLogEntry) { + data, _ := json.Marshal(entry) + topics := []string{"contract_log", "contract:" + entry.ContractID} + h.fanout(wsServerFrame{Event: "contract_log", Data: data}, topics) +} + +// EmitInbox pushes a relay envelope summary to subscribers of the recipient's +// inbox topic. `envelope` is the full relay.Envelope but we only serialise a +// minimal shape here — the client can refetch from /api/relay/inbox for the +// ciphertext if it missed a frame. +// +// Called from relay.Mailbox.onStore (wired in cmd/node/main.go). Avoids +// importing the relay package here to keep the hub dependency-light. +func (h *WSHub) EmitInbox(recipientX25519 string, envelopeSummary any) { + data, err := json.Marshal(envelopeSummary) + if err != nil { + return + } + topics := []string{"inbox", "inbox:" + recipientX25519} + h.fanout(wsServerFrame{Event: "inbox", Data: data}, topics) +} + +// EmitBlockWithTxs is the matching convenience method of SSEHub. +func (h *WSHub) EmitBlockWithTxs(b *blockchain.Block) { + h.EmitBlock(b) + for _, tx := range b.Transactions { + h.EmitTx(tx) + } +} + +// ── per-client bookkeeping ─────────────────────────────────────────────────── + +type wsClient struct { + conn *websocket.Conn + send chan []byte + mu sync.RWMutex + subs map[string]struct{} + + // auth state. authNonce is set when the connection opens; the client + // signs it and sends via the `auth` op. On success we store the + // pubkey and (if available) the matching X25519 key so scoped + // subscriptions can be validated without a DB lookup on each op. + authNonce string + authPubKey string // Ed25519 pubkey hex, empty = unauthenticated + authX25519 string // X25519 pubkey hex, empty if not looked up + + // remoteIP is stored so removeClient can decrement the per-IP counter. + remoteIP string + + // tokenOK is set at upgrade time: true if the connection passed the + // access-token check (or no token was required). submit_tx ops are + // rejected when tokenOK is false — matches the HTTP POST /api/tx + // behaviour so a private node's write surface is uniform across + // transports. + tokenOK bool +} + +func (c *wsClient) sendFrame(f wsServerFrame) { + buf, err := json.Marshal(f) + if err != nil { + return + } + select { + case c.send <- buf: + default: + // Client outbox full; drop. The lag indicator in fanout handles + // recovery notifications; control-plane frames (errors/acks) from + // readLoop are best-effort. + _ = fmt.Sprint // silence unused import on trimmed builds + } +} diff --git a/p2p/host.go b/p2p/host.go new file mode 100644 index 0000000..34e7206 --- /dev/null +++ b/p2p/host.go @@ -0,0 +1,469 @@ +// Package p2p wraps go-libp2p with gossipsub and Kademlia DHT. +// The host uses the node's Ed25519 identity so the peer ID is deterministic +// across restarts. +package p2p + +import ( + "bufio" + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + "log" + "time" + + libp2p "github.com/libp2p/go-libp2p" + dht "github.com/libp2p/go-libp2p-kad-dht" + pubsub "github.com/libp2p/go-libp2p-pubsub" + libp2pcrypto "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/p2p/discovery/mdns" + "github.com/libp2p/go-libp2p/p2p/discovery/routing" + discutil "github.com/libp2p/go-libp2p/p2p/discovery/util" + "github.com/multiformats/go-multiaddr" + + "go-blockchain/blockchain" + "go-blockchain/identity" +) + +const ( + // Gossipsub topics (for non-consensus broadcast) + TopicTx = "dchain/tx/v1" + TopicBlocks = "dchain/blocks/v1" // committed block broadcast + + // Direct stream protocols (for reliable small-N validator consensus) + ConsensusStreamProto = "/dchain/consensus/1.0.0" + + DiscoveryNS = "dchain-v1" + mDNSServiceTag = "dchain-mdns" +) + +// Host is a libp2p host with gossipsub topics and peer discovery. +// Consensus messages use per-peer persistent streams — this guarantees +// in-order delivery, which is critical for PBFT (PRE-PREPARE must arrive +// before PREPARE from the same sender). +type Host struct { + h host.Host + dhtNode *dht.IpfsDHT + ps *pubsub.PubSub // exposed for relay and other topic consumers + + // Gossipsub for block and tx propagation + txTopic *pubsub.Topic + blocksTopic *pubsub.Topic + txSub *pubsub.Subscription + blocksSub *pubsub.Subscription + + // connHandlers is called when a new peer connects. + connHandlers []func(peer.ID) + + // versionAnnouncer is the peer-version gossip subsystem, set by + // StartVersionGossip. nil until that's called (e.g. during tests). + versionAnnouncer *versionAnnouncer +} + +// NewHost creates a libp2p host. +// The Ed25519 identity key is used so the peer ID is stable across restarts. +// +// announceAddrs, if non-nil, replaces the addresses advertised to peers. +// Use this when the node runs on a server with a public IP that differs from +// the listen interface (VPS, Docker, NAT), e.g.: +// +// []multiaddr.Multiaddr{multiaddr.StringCast("/ip4/1.2.3.4/tcp/4001")} +// +// Without announceAddrs the host tries UPnP/NAT-PMP (libp2p.NATPortMap). +// On a direct-IP VPS or in Docker with a fixed backbone IP, pass the address +// explicitly — otherwise peers will receive unreachable internal addresses. +func NewHost(ctx context.Context, id *identity.Identity, listenAddr string, announceAddrs []multiaddr.Multiaddr) (*Host, error) { + ma, err := multiaddr.NewMultiaddr(listenAddr) + if err != nil { + return nil, fmt.Errorf("bad listen addr: %w", err) + } + + // Convert stdlib Ed25519 key → libp2p crypto.PrivKey + privStd := ed25519.PrivateKey(id.PrivKey) + lk, _, err := libp2pcrypto.KeyPairFromStdKey(&privStd) + if err != nil { + return nil, fmt.Errorf("convert identity key: %w", err) + } + + opts := []libp2p.Option{ + libp2p.ListenAddrs(ma), + libp2p.Identity(lk), + libp2p.NATPortMap(), + } + // Override advertised addresses when explicit announce addrs are provided. + // Required for internet deployment: without this libp2p advertises the + // bind interface (0.0.0.0 → internal/loopback) which remote peers cannot reach. + if len(announceAddrs) > 0 { + announce := announceAddrs + opts = append(opts, libp2p.AddrsFactory(func(_ []multiaddr.Multiaddr) []multiaddr.Multiaddr { + return announce + })) + } + + h, err := libp2p.New(opts...) + if err != nil { + return nil, fmt.Errorf("create libp2p host: %w", err) + } + + // Kademlia DHT for peer discovery. + // dht.BootstrapPeers() with no args disables the default public IPFS nodes: + // this is a private chain, we don't want to gossip with the global IPFS + // network. Peer discovery happens via our own --peers bootstrap nodes. + kadDHT, err := dht.New(ctx, h, + dht.Mode(dht.ModeAutoServer), + dht.BootstrapPeers(), // empty — private network only + ) + if err != nil { + h.Close() + return nil, fmt.Errorf("create dht: %w", err) + } + if err := kadDHT.Bootstrap(ctx); err != nil { + h.Close() + return nil, fmt.Errorf("dht bootstrap: %w", err) + } + + // GossipSub — only for blocks and transactions (not consensus) + ps, err := pubsub.NewGossipSub(ctx, h) + if err != nil { + h.Close() + return nil, fmt.Errorf("create gossipsub: %w", err) + } + + txTopic, err := ps.Join(TopicTx) + if err != nil { + return nil, err + } + blocksTopic, err := ps.Join(TopicBlocks) + if err != nil { + return nil, err + } + txSub, err := txTopic.Subscribe() + if err != nil { + return nil, err + } + blocksSub, err := blocksTopic.Subscribe() + if err != nil { + return nil, err + } + + node := &Host{ + h: h, + dhtNode: kadDHT, + ps: ps, + txTopic: txTopic, + blocksTopic: blocksTopic, + txSub: txSub, + blocksSub: blocksSub, + } + + // mDNS — automatic discovery on the same LAN / Docker bridge network + mdnsSvc := mdns.NewMdnsService(h, mDNSServiceTag, &mdnsNotifee{node: node}) + if err := mdnsSvc.Start(); err != nil { + log.Printf("[P2P] mDNS start error (non-fatal): %v", err) + } + + // Notify connHandlers when a new peer connects + h.Network().Notify(&network.NotifyBundle{ + ConnectedF: func(_ network.Network, c network.Conn) { + go func() { + for _, fn := range node.connHandlers { + fn(c.RemotePeer()) + } + }() + }, + }) + + log.Printf("[P2P] node started id=%s", h.ID()) + for _, addr := range h.Addrs() { + log.Printf("[P2P] %s/p2p/%s", addr, h.ID()) + } + return node, nil +} + +// PeerID returns this node's libp2p peer ID string. +func (n *Host) PeerID() string { + return n.h.ID().String() +} + +// OnPeerConnected registers a callback called when a new peer connects. +func (n *Host) OnPeerConnected(fn func(peer.ID)) { + n.connHandlers = append(n.connHandlers, fn) +} + +// Advertise announces this node under DiscoveryNS in the DHT. +func (n *Host) Advertise(ctx context.Context) { + rd := routing.NewRoutingDiscovery(n.dhtNode) + discutil.Advertise(ctx, rd, DiscoveryNS) +} + +// DiscoverPeers continuously searches the DHT for new peers. +// Runs a persistent loop: after each FindPeers round it waits 60 s and +// tries again, so the node reconnects after network partitions or restarts. +func (n *Host) DiscoverPeers(ctx context.Context) { + rd := routing.NewRoutingDiscovery(n.dhtNode) + go func() { + for { + select { + case <-ctx.Done(): + return + default: + } + ch, err := rd.FindPeers(ctx, DiscoveryNS) + if err != nil { + select { + case <-ctx.Done(): + return + case <-time.After(30 * time.Second): + } + continue + } + for p := range ch { + if p.ID == n.h.ID() { + continue + } + if n.h.Network().Connectedness(p.ID) == 0 { + if err := n.h.Connect(ctx, p); err == nil { + log.Printf("[P2P] DHT discovered %s", p.ID) + } + } + } + // Wait before the next discovery round. + select { + case <-ctx.Done(): + return + case <-time.After(60 * time.Second): + } + } + }() +} + +// Connect dials a peer by full multiaddr (must include /p2p/). +func (n *Host) Connect(ctx context.Context, addrStr string) error { + ma, err := multiaddr.NewMultiaddr(addrStr) + if err != nil { + return err + } + pi, err := peer.AddrInfoFromP2pAddr(ma) + if err != nil { + return err + } + return n.h.Connect(ctx, *pi) +} + +// SetConsensusMsgHandler registers the direct-stream handler for consensus messages. +// Messages from each connected peer are decoded and passed to handler. +func (n *Host) SetConsensusMsgHandler(handler func(*blockchain.ConsensusMsg)) { + n.h.SetStreamHandler(ConsensusStreamProto, func(s network.Stream) { + defer s.Close() + if err := s.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + log.Printf("[P2P] consensus stream deadline error: %v", err) + } + scanner := bufio.NewScanner(s) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + for scanner.Scan() { + var msg blockchain.ConsensusMsg + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + log.Printf("[P2P] bad consensus msg: %v", err) + continue + } + handler(&msg) + } + }) +} + +// BroadcastConsensus sends a ConsensusMsg directly to all connected peers. +// Uses dedicated streams — reliable for small validator sets. +func (n *Host) BroadcastConsensus(msg *blockchain.ConsensusMsg) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + data = append(data, '\n') + + peers := n.h.Network().Peers() + for _, pid := range peers { + pid := pid + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + s, err := n.h.NewStream(ctx, pid, ConsensusStreamProto) + if err != nil { + return // peer may not support this protocol yet + } + defer s.Close() + if err := s.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + log.Printf("[P2P] consensus write deadline to %s: %v", pid, err) + return + } + if _, err := s.Write(data); err != nil { + log.Printf("[P2P] consensus write to %s: %v", pid, err) + } + }() + } + return nil +} + +// PublishTx broadcasts a Transaction. +func (n *Host) PublishTx(tx *blockchain.Transaction) error { + data, err := json.Marshal(tx) + if err != nil { + return err + } + return n.txTopic.Publish(context.Background(), data) +} + +// PublishBlock broadcasts a committed block so peers can sync. +func (n *Host) PublishBlock(b *blockchain.Block) error { + data, err := json.Marshal(b) + if err != nil { + return err + } + return n.blocksTopic.Publish(context.Background(), data) +} + +// TxMsgs returns a channel of incoming Transactions from peers. +func (n *Host) TxMsgs(ctx context.Context) <-chan *blockchain.Transaction { + ch := make(chan *blockchain.Transaction, 64) + go func() { + defer close(ch) + for { + m, err := n.txSub.Next(ctx) + if err != nil { + return + } + if m.ReceivedFrom == n.h.ID() { + continue + } + var tx blockchain.Transaction + if err := json.Unmarshal(m.Data, &tx); err != nil { + continue + } + select { + case ch <- &tx: + case <-ctx.Done(): + return + } + } + }() + return ch +} + +// BlockMsg is a gossip-received block along with the peer that forwarded it +// to us. Used by the main node loop so gap-fill can ask the gossiper for the +// missing blocks between tip and the received one. +type BlockMsg struct { + Block *blockchain.Block + From peer.ID +} + +// BlockMsgs returns a channel of committed blocks broadcast by peers. +// The channel item includes the forwarding peer ID so callers can drive +// gap-fill sync from whichever peer just proved it has the new tip. +func (n *Host) BlockMsgs(ctx context.Context) <-chan BlockMsg { + ch := make(chan BlockMsg, 64) + go func() { + defer close(ch) + for { + m, err := n.blocksSub.Next(ctx) + if err != nil { + return + } + if m.ReceivedFrom == n.h.ID() { + continue + } + var b blockchain.Block + if err := json.Unmarshal(m.Data, &b); err != nil { + continue + } + select { + case ch <- BlockMsg{Block: &b, From: m.ReceivedFrom}: + case <-ctx.Done(): + return + } + } + }() + return ch +} + +// PeerCount returns number of connected peers. +func (n *Host) PeerCount() int { + return len(n.h.Network().Peers()) +} + +// Peers returns all connected peer IDs. +func (n *Host) Peers() []peer.ID { + return n.h.Network().Peers() +} + +// LibP2PHost exposes the underlying host for the sync protocol. +func (n *Host) LibP2PHost() host.Host { + return n.h +} + +// GossipSub returns the underlying PubSub instance so callers can join +// additional topics (e.g. the relay envelope topic). +func (n *Host) GossipSub() *pubsub.PubSub { + return n.ps +} + +// AddrStrings returns all full multiaddrs for this host. +func (n *Host) AddrStrings() []string { + var out []string + for _, a := range n.h.Addrs() { + out = append(out, fmt.Sprintf("%s/p2p/%s", a, n.h.ID())) + } + return out +} + +// ConnectedPeerInfo describes one currently-connected remote peer. +// Used by the /api/peers endpoint so new joiners can download a live seed +// list from any existing node and bootstrap their libp2p connectivity. +type ConnectedPeerInfo struct { + ID string `json:"id"` + Addrs []string `json:"addrs"` +} + +// ConnectedPeers returns every peer in the network's current view with their +// full libp2p multiaddrs (suffixed with /p2p/). Addresses come from the +// peerstore, which includes both dialed and received connections. +// +// Safe to call concurrently while the host is running; does not hold any +// lock beyond libp2p's internal peerstore lock. +func (n *Host) ConnectedPeers() []ConnectedPeerInfo { + peers := n.h.Network().Peers() + out := make([]ConnectedPeerInfo, 0, len(peers)) + for _, pid := range peers { + addrs := n.h.Peerstore().Addrs(pid) + addrStrs := make([]string, 0, len(addrs)) + for _, a := range addrs { + addrStrs = append(addrStrs, fmt.Sprintf("%s/p2p/%s", a, pid)) + } + out = append(out, ConnectedPeerInfo{ + ID: pid.String(), + Addrs: addrStrs, + }) + } + return out +} + +// Close shuts down the host. +func (n *Host) Close() error { + return n.h.Close() +} + +// --- mDNS notifee --- + +type mdnsNotifee struct{ node *Host } + +func (m *mdnsNotifee) HandlePeerFound(pi peer.AddrInfo) { + if pi.ID == m.node.h.ID() { + return + } + log.Printf("[P2P] mDNS found peer %s — connecting", pi.ID) + if err := m.node.h.Connect(context.Background(), pi); err != nil { + log.Printf("[P2P] mDNS connect to %s failed: %v", pi.ID, err) + } +} diff --git a/p2p/sync.go b/p2p/sync.go new file mode 100644 index 0000000..8f0b516 --- /dev/null +++ b/p2p/sync.go @@ -0,0 +1,168 @@ +// Package p2p — chain sync protocol. +// +// Sync protocol "/dchain/sync/1.0.0": +// +// Request → {"from": N, "to": M} +// Response → newline-delimited JSON blocks (index N … M), then EOF +// +// Height protocol "/dchain/height/1.0.0": +// +// Request → (empty) +// Response → {"height": N} +package p2p + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "time" + + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + + "go-blockchain/blockchain" +) + +const ( + SyncProtocol = "/dchain/sync/1.0.0" + HeightProtocol = "/dchain/height/1.0.0" + + syncTimeout = 30 * time.Second +) + +type syncRequest struct { + From uint64 `json:"from"` + To uint64 `json:"to"` +} + +type heightResponse struct { + Height uint64 `json:"height"` +} + +// SetSyncHandler registers the block-sync stream handler. +// getBlock must be safe to call concurrently. +func (n *Host) SetSyncHandler( + getBlock func(index uint64) (*blockchain.Block, error), + getHeight func() uint64, +) { + n.h.SetStreamHandler(SyncProtocol, func(s network.Stream) { + defer s.Close() + if err := s.SetDeadline(time.Now().Add(syncTimeout)); err != nil { + log.Printf("[SYNC] set deadline error: %v", err) + return + } + + var req syncRequest + if err := json.NewDecoder(s).Decode(&req); err != nil { + return + } + + log.Printf("[SYNC] serving blocks %d–%d to %s", req.From, req.To, s.Conn().RemotePeer()) + enc := json.NewEncoder(s) + for i := req.From; i <= req.To; i++ { + b, err := getBlock(i) + if err != nil { + break // peer asks for a block we don't have yet + } + if err := enc.Encode(b); err != nil { + return + } + } + }) + + n.h.SetStreamHandler(HeightProtocol, func(s network.Stream) { + defer s.Close() + if err := s.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + log.Printf("[SYNC] set height deadline error: %v", err) + return + } + resp := heightResponse{Height: getHeight()} + if err := json.NewEncoder(s).Encode(resp); err != nil { + log.Printf("[SYNC] encode height response error: %v", err) + } + }) +} + +// QueryPeerHeight returns the chain height of a connected peer. +func (n *Host) QueryPeerHeight(ctx context.Context, peerID peer.ID) (uint64, error) { + s, err := n.h.NewStream(ctx, peerID, HeightProtocol) + if err != nil { + return 0, fmt.Errorf("open height stream to %s: %w", peerID, err) + } + defer s.Close() + if err := s.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return 0, fmt.Errorf("set height deadline: %w", err) + } + + var resp heightResponse + if err := json.NewDecoder(s).Decode(&resp); err != nil { + return 0, fmt.Errorf("decode height: %w", err) + } + return resp.Height, nil +} + +// SyncBlocks fetches blocks [from, to] from a peer and returns them in order. +func (n *Host) SyncBlocks(ctx context.Context, peerID peer.ID, from, to uint64) ([]*blockchain.Block, error) { + s, err := n.h.NewStream(ctx, peerID, SyncProtocol) + if err != nil { + return nil, fmt.Errorf("open sync stream to %s: %w", peerID, err) + } + defer s.Close() + if err := s.SetDeadline(time.Now().Add(syncTimeout)); err != nil { + return nil, fmt.Errorf("set sync deadline: %w", err) + } + + req := syncRequest{From: from, To: to} + if err := json.NewEncoder(s).Encode(req); err != nil { + return nil, fmt.Errorf("send sync req: %w", err) + } + if err := s.CloseWrite(); err != nil { + return nil, fmt.Errorf("close sync write: %w", err) + } + + var blocks []*blockchain.Block + scanner := bufio.NewScanner(io.LimitReader(s, 100<<20)) // 100 MiB max + scanner.Buffer(make([]byte, 1<<20), 1<<20) + for scanner.Scan() { + var b blockchain.Block + if err := json.Unmarshal(scanner.Bytes(), &b); err != nil { + return nil, fmt.Errorf("decode block: %w", err) + } + blocks = append(blocks, &b) + } + return blocks, scanner.Err() +} + +// SyncFromPeerFull syncs all blocks that the peer has but we don't. +// localCount = number of blocks we already have (0 if empty, N if we have blocks 0..N-1). +// The peer reports its own block count; we fetch [localCount .. peerCount-1]. +// Each block is passed to applyFn in ascending index order. +// Returns the number of blocks synced. +func (n *Host) SyncFromPeerFull(ctx context.Context, peerID peer.ID, localCount uint64, applyFn func(*blockchain.Block) error) (int, error) { + peerCount, err := n.QueryPeerHeight(ctx, peerID) + if err != nil { + return 0, fmt.Errorf("query height: %w", err) + } + if peerCount <= localCount { + return 0, nil // already up to date + } + + from := localCount // first missing block index + to := peerCount - 1 // last block index peer has + log.Printf("[SYNC] syncing blocks %d–%d from peer %s", from, to, peerID) + + blocks, err := n.SyncBlocks(ctx, peerID, from, to) + if err != nil { + return 0, err + } + + for _, b := range blocks { + if err := applyFn(b); err != nil { + return 0, fmt.Errorf("apply block #%d: %w", b.Index, err) + } + } + return len(blocks), nil +} diff --git a/p2p/version_gossip.go b/p2p/version_gossip.go new file mode 100644 index 0000000..d0b2fab --- /dev/null +++ b/p2p/version_gossip.go @@ -0,0 +1,220 @@ +// Package p2p — peer version discovery via gossipsub. +// +// What this solves +// ──────────────── +// A decentralized node fleet has no registry telling each operator what +// version everyone else is running. Without that knowledge: +// +// • We can't decide when it's safe to activate a new feature-flag tx +// (§5.2 of UPDATE_STRATEGY.md) — activation must wait until ≥N% of +// the network has the new binary. +// • Operators can't see at a glance "am I the one holding back an +// upgrade?" — because their node's Explorer had no way to ask peers. +// • Clients can't warn the user "this node is running a pre-channels +// build" without making N extra HTTP round-trips. +// +// How it works +// ──────────── +// A small gossipsub topic — `dchain/version/v1` — carries a JSON blob from +// each node: +// +// { +// "peer_id": "12D3KooW…", +// "tag": "v0.5.1", +// "commit": "abc1234…", +// "protocol_version": 1, +// "timestamp": 1715000000 +// } +// +// Every node: +// 1. Publishes its own blob every 60 seconds. +// 2. Subscribes to the topic and keeps a bounded in-memory map +// peer.ID → latest announce. +// 3. Evicts entries older than 15 minutes (peer disconnect / stale). +// +// Messages are unsigned and advisory — a peer lying about its version is +// detectable when their blocks/txs use unsupported fields (consensus will +// reject), so we don't add a signature layer here. The map is pure UX. +// +// Memory budget: ~200 bytes per peer × bounded by connected peer count. +// Topic traffic: ~300 bytes every 60s per peer → trivial for a libp2p fleet. +package p2p + +import ( + "context" + "encoding/json" + "log" + "sync" + "time" + + pubsub "github.com/libp2p/go-libp2p-pubsub" + "github.com/libp2p/go-libp2p/core/peer" + + "go-blockchain/node/version" +) + +// TopicVersion is the gossipsub topic for peer-version announces. +const TopicVersion = "dchain/version/v1" + +const ( + versionGossipInterval = 60 * time.Second + versionGossipTTL = 15 * time.Minute +) + +// PeerVersion is one peer's self-reported identity. +type PeerVersion struct { + PeerID string `json:"peer_id"` + Tag string `json:"tag"` + Commit string `json:"commit"` + ProtocolVersion int `json:"protocol_version"` + Timestamp int64 `json:"timestamp"` + ReceivedAt time.Time `json:"received_at,omitempty"` +} + +// versionAnnouncer is wired into Host via StartVersionGossip. Holds the +// publish topic + subscription + the latest-seen map under its own mutex, +// so read path (PeerVersions) is lock-free against publish. +type versionAnnouncer struct { + h *Host + topic *pubsub.Topic + sub *pubsub.Subscription + protoVer int + mu sync.RWMutex + latest map[peer.ID]PeerVersion +} + +// StartVersionGossip joins the version topic, spawns the publisher loop and +// the subscriber loop, and returns. Both goroutines run until ctx is done. +// +// Call exactly once per Host. protocolVersion should be node.ProtocolVersion +// (the compile-time wire-protocol const) — threaded through as an int to +// avoid an import cycle (p2p → node would be circular; node → p2p already +// exists via the host injection). +func (n *Host) StartVersionGossip(ctx context.Context, protocolVersion int) error { + topic, err := n.ps.Join(TopicVersion) + if err != nil { + return err + } + sub, err := topic.Subscribe() + if err != nil { + return err + } + va := &versionAnnouncer{ + h: n, + topic: topic, + sub: sub, + protoVer: protocolVersion, + latest: make(map[peer.ID]PeerVersion), + } + n.versionAnnouncer = va + go va.publishLoop(ctx) + go va.subscribeLoop(ctx) + go va.evictLoop(ctx) + return nil +} + +// PeerVersions returns a snapshot of every peer's last-known version. +// Result is a copy — caller can iterate without a lock. +func (n *Host) PeerVersions() map[string]PeerVersion { + if n.versionAnnouncer == nil { + return nil + } + va := n.versionAnnouncer + va.mu.RLock() + defer va.mu.RUnlock() + out := make(map[string]PeerVersion, len(va.latest)) + for pid, v := range va.latest { + out[pid.String()] = v + } + return out +} + +func (va *versionAnnouncer) publishLoop(ctx context.Context) { + // First publish immediately so peers who just joined learn our version + // without a minute of lag. + va.publishOnce(ctx) + t := time.NewTicker(versionGossipInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + va.publishOnce(ctx) + } + } +} + +func (va *versionAnnouncer) publishOnce(ctx context.Context) { + msg := PeerVersion{ + PeerID: va.h.h.ID().String(), + Tag: version.Tag, + Commit: version.Commit, + ProtocolVersion: va.protoVer, + Timestamp: time.Now().Unix(), + } + b, err := json.Marshal(msg) + if err != nil { + log.Printf("[P2P] version gossip marshal: %v", err) + return + } + pubCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := va.topic.Publish(pubCtx, b); err != nil { + log.Printf("[P2P] version gossip publish: %v", err) + } +} + +func (va *versionAnnouncer) subscribeLoop(ctx context.Context) { + for { + m, err := va.sub.Next(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + log.Printf("[P2P] version gossip recv: %v", err) + continue + } + // Skip our own broadcasts — gossipsub delivers them back to us by + // default. Without this we'd overwrite our own "received" timestamp + // every minute and clutter metrics. + if m.ReceivedFrom == va.h.h.ID() { + continue + } + var pv PeerVersion + if err := json.Unmarshal(m.Data, &pv); err != nil { + log.Printf("[P2P] version gossip bad msg from %s: %v", m.ReceivedFrom, err) + continue + } + // Source validation: the peer ID inside the message must match the + // peer that sent it. Otherwise a node could spoof "version" rows + // for peers it doesn't control, confusing the UX. + if pv.PeerID != m.ReceivedFrom.String() { + continue + } + pv.ReceivedAt = time.Now() + va.mu.Lock() + va.latest[m.ReceivedFrom] = pv + va.mu.Unlock() + } +} + +func (va *versionAnnouncer) evictLoop(ctx context.Context) { + t := time.NewTicker(versionGossipTTL / 3) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-t.C: + cutoff := now.Add(-versionGossipTTL) + va.mu.Lock() + for pid, v := range va.latest { + if v.ReceivedAt.Before(cutoff) { + delete(va.latest, pid) + } + } + va.mu.Unlock() + } + } +} diff --git a/relay/envelope.go b/relay/envelope.go new file mode 100644 index 0000000..162deba --- /dev/null +++ b/relay/envelope.go @@ -0,0 +1,139 @@ +// Package relay implements NaCl-box encrypted envelope routing over gossipsub. +// Messages are sealed for a specific recipient's X25519 public key; relay nodes +// propagate them without being able to read the contents. +// +// Economic model: the sender pre-authorises a delivery fee by signing +// FeeAuthBytes(envelopeID, feeUT) with their Ed25519 identity key. When the +// relay delivers the envelope, it submits a RELAY_PROOF transaction on-chain +// that pulls feeUT from the sender's balance and credits the relay. +package relay + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + + "golang.org/x/crypto/nacl/box" + + "go-blockchain/blockchain" + "go-blockchain/identity" +) + +// KeyPair holds an X25519 keypair used exclusively for relay envelope encryption. +// This is separate from the node's Ed25519 identity keypair. +type KeyPair struct { + Pub [32]byte + Priv [32]byte +} + +// GenerateKeyPair creates a fresh X25519 keypair. +func GenerateKeyPair() (*KeyPair, error) { + pub, priv, err := box.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate relay keypair: %w", err) + } + return &KeyPair{Pub: *pub, Priv: *priv}, nil +} + +// PubHex returns the hex-encoded X25519 public key. +func (kp *KeyPair) PubHex() string { + return hex.EncodeToString(kp.Pub[:]) +} + +// Envelope is a sealed message routed via relay nodes. +// Only the holder of the matching X25519 private key can decrypt it. +type Envelope struct { + // ID is the hex-encoded first 16 bytes of SHA-256(nonce || ciphertext). + ID string `json:"id"` + RecipientPub string `json:"recipient_pub"` // hex X25519 public key + SenderPub string `json:"sender_pub"` // hex X25519 public key (for decryption) + + // Fee authorization: sender pre-signs permission for relay to pull FeeUT. + SenderEd25519PubKey string `json:"sender_ed25519_pub"` // sender's blockchain identity key (hex) + FeeUT uint64 `json:"fee_ut"` // µT the relay may claim on delivery + FeeSig []byte `json:"fee_sig"` // Ed25519 sig over FeeAuthBytes(ID, FeeUT) + + Nonce []byte `json:"nonce"` // 24 bytes + Ciphertext []byte `json:"ciphertext"` // NaCl box ciphertext + SentAt int64 `json:"sent_at"` // unix timestamp (informational) +} + +// Seal encrypts msg for recipientPub and attaches a fee authorization. +// senderID is the sender's Ed25519 identity used to sign the fee authorisation. +// feeUT is the delivery fee offered to the relay — 0 means free delivery. +func Seal( + sender *KeyPair, + senderID *identity.Identity, + recipientPub [32]byte, + msg []byte, + feeUT uint64, + sentAt int64, +) (*Envelope, error) { + var nonce [24]byte + if _, err := rand.Read(nonce[:]); err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + ct := box.Seal(nil, msg, &nonce, &recipientPub, &sender.Priv) + envID := envelopeID(nonce[:], ct) + + var feeSig []byte + if feeUT > 0 && senderID != nil { + authBytes := blockchain.FeeAuthBytes(envID, feeUT) + feeSig = senderID.Sign(authBytes) + } + + return &Envelope{ + ID: envID, + RecipientPub: hex.EncodeToString(recipientPub[:]), + SenderPub: sender.PubHex(), + SenderEd25519PubKey: func() string { + if senderID != nil { + return senderID.PubKeyHex() + } + return "" + }(), + FeeUT: feeUT, + FeeSig: feeSig, + Nonce: nonce[:], + Ciphertext: ct, + SentAt: sentAt, + }, nil +} + +// Open decrypts an envelope using the recipient's private key. +func Open(recipient *KeyPair, env *Envelope) ([]byte, error) { + senderBytes, err := hex.DecodeString(env.SenderPub) + if err != nil || len(senderBytes) != 32 { + return nil, fmt.Errorf("invalid sender pub key") + } + if len(env.Nonce) != 24 { + return nil, fmt.Errorf("invalid nonce: expected 24 bytes, got %d", len(env.Nonce)) + } + var senderPub [32]byte + var nonce [24]byte + copy(senderPub[:], senderBytes) + copy(nonce[:], env.Nonce) + + msg, ok := box.Open(nil, env.Ciphertext, &nonce, &senderPub, &recipient.Priv) + if !ok { + return nil, fmt.Errorf("decryption failed: not addressed to this key or data is corrupt") + } + return msg, nil +} + +// Hash returns the SHA-256 of (nonce || ciphertext), used in RELAY_PROOF payloads. +func Hash(env *Envelope) []byte { + h := sha256.Sum256(append(env.Nonce, env.Ciphertext...)) + return h[:] +} + +// IsAddressedTo reports whether the envelope is addressed to the given keypair. +func (e *Envelope) IsAddressedTo(kp *KeyPair) bool { + return e.RecipientPub == kp.PubHex() +} + +func envelopeID(nonce, ct []byte) string { + h := sha256.Sum256(append(nonce, ct...)) + return hex.EncodeToString(h[:16]) +} diff --git a/relay/envelope_test.go b/relay/envelope_test.go new file mode 100644 index 0000000..afb64e9 --- /dev/null +++ b/relay/envelope_test.go @@ -0,0 +1,191 @@ +package relay_test + +import ( + "bytes" + "testing" + "time" + + "go-blockchain/identity" + "go-blockchain/relay" +) + +func mustGenerateKeyPair(t *testing.T) *relay.KeyPair { + t.Helper() + kp, err := relay.GenerateKeyPair() + if err != nil { + t.Fatalf("GenerateKeyPair: %v", err) + } + return kp +} + +func mustGenerateIdentity(t *testing.T) *identity.Identity { + t.Helper() + id, err := identity.Generate() + if err != nil { + t.Fatalf("identity.Generate: %v", err) + } + return id +} + +// TestSealOpenRoundTrip seals a message and opens it with the correct recipient key. +func TestSealOpenRoundTrip(t *testing.T) { + sender := mustGenerateKeyPair(t) + recipient := mustGenerateKeyPair(t) + plaintext := []byte("hello relay world") + + env, err := relay.Seal(sender, nil, recipient.Pub, plaintext, 0, time.Now().Unix()) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + got, err := relay.Open(recipient, env) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("plaintext mismatch: got %q, want %q", got, plaintext) + } +} + +// TestOpenWrongKey attempts to open an envelope with a different keypair. +func TestOpenWrongKey(t *testing.T) { + sender := mustGenerateKeyPair(t) + recipient := mustGenerateKeyPair(t) + wrong := mustGenerateKeyPair(t) + + env, err := relay.Seal(sender, nil, recipient.Pub, []byte("secret"), 0, time.Now().Unix()) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + _, err = relay.Open(wrong, env) + if err == nil { + t.Fatal("Open with wrong key should return an error") + } +} + +// TestIsAddressedTo checks that IsAddressedTo returns true for the correct +// recipient and false for a different keypair. +func TestIsAddressedTo(t *testing.T) { + sender := mustGenerateKeyPair(t) + recipient := mustGenerateKeyPair(t) + other := mustGenerateKeyPair(t) + + env, err := relay.Seal(sender, nil, recipient.Pub, []byte("msg"), 0, time.Now().Unix()) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + if !env.IsAddressedTo(recipient) { + t.Error("IsAddressedTo should return true for the correct recipient") + } + if env.IsAddressedTo(other) { + t.Error("IsAddressedTo should return false for a different keypair") + } +} + +// TestSealWithFee seals a message with a positive fee and a real sender identity. +// FeeSig must be non-nil and SenderEd25519PubKey must be populated. +func TestSealWithFee(t *testing.T) { + sender := mustGenerateKeyPair(t) + senderID := mustGenerateIdentity(t) + recipient := mustGenerateKeyPair(t) + + env, err := relay.Seal(sender, senderID, recipient.Pub, []byte("paid msg"), 1000, time.Now().Unix()) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + if len(env.FeeSig) == 0 { + t.Error("FeeSig should be non-nil when feeUT > 0 and senderID is provided") + } + if env.SenderEd25519PubKey == "" { + t.Error("SenderEd25519PubKey should be set when senderID is provided") + } + if env.SenderEd25519PubKey != senderID.PubKeyHex() { + t.Errorf("SenderEd25519PubKey mismatch: got %s, want %s", + env.SenderEd25519PubKey, senderID.PubKeyHex()) + } + if env.FeeUT != 1000 { + t.Errorf("FeeUT: got %d, want 1000", env.FeeUT) + } +} + +// TestSealNilSenderID seals with nil senderID and feeUT=0. +// FeeSig should be nil and SenderEd25519PubKey should be empty. +func TestSealNilSenderID(t *testing.T) { + sender := mustGenerateKeyPair(t) + recipient := mustGenerateKeyPair(t) + + env, err := relay.Seal(sender, nil, recipient.Pub, []byte("free msg"), 0, time.Now().Unix()) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + if env.FeeSig != nil { + t.Error("FeeSig should be nil for zero-fee envelope with nil senderID") + } + if env.SenderEd25519PubKey != "" { + t.Errorf("SenderEd25519PubKey should be empty, got %s", env.SenderEd25519PubKey) + } +} + +// TestEnvelopeIDUnique verifies that two seals of the same message produce +// different IDs because random nonces are used each time. +func TestEnvelopeIDUnique(t *testing.T) { + sender := mustGenerateKeyPair(t) + recipient := mustGenerateKeyPair(t) + msg := []byte("same message") + sentAt := time.Now().Unix() + + env1, err := relay.Seal(sender, nil, recipient.Pub, msg, 0, sentAt) + if err != nil { + t.Fatalf("Seal 1: %v", err) + } + env2, err := relay.Seal(sender, nil, recipient.Pub, msg, 0, sentAt) + if err != nil { + t.Fatalf("Seal 2: %v", err) + } + + if env1.ID == env2.ID { + t.Error("two seals of the same message should produce different IDs") + } +} + +// TestOpenTamperedCiphertext flips a byte in the ciphertext and expects Open to fail. +func TestOpenTamperedCiphertext(t *testing.T) { + sender := mustGenerateKeyPair(t) + recipient := mustGenerateKeyPair(t) + + env, err := relay.Seal(sender, nil, recipient.Pub, []byte("tamper me"), 0, time.Now().Unix()) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + // Flip the first byte of the ciphertext. + env.Ciphertext[0] ^= 0xFF + + _, err = relay.Open(recipient, env) + if err == nil { + t.Fatal("Open with tampered ciphertext should return an error") + } +} + +// TestOpenTamperedNonce flips a byte in the nonce and expects Open to fail. +func TestOpenTamperedNonce(t *testing.T) { + sender := mustGenerateKeyPair(t) + recipient := mustGenerateKeyPair(t) + + env, err := relay.Seal(sender, nil, recipient.Pub, []byte("tamper nonce"), 0, time.Now().Unix()) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + // Flip the first byte of the nonce. + env.Nonce[0] ^= 0xFF + + _, err = relay.Open(recipient, env) + if err == nil { + t.Fatal("Open with tampered nonce should return an error") + } +} diff --git a/relay/keypair.go b/relay/keypair.go new file mode 100644 index 0000000..fae1286 --- /dev/null +++ b/relay/keypair.go @@ -0,0 +1,54 @@ +package relay + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" +) + +type keypairFile struct { + Pub string `json:"pub"` + Priv string `json:"priv"` +} + +// LoadOrCreateKeyPair loads an X25519 relay keypair from path, creating it if absent. +func LoadOrCreateKeyPair(path string) (*KeyPair, error) { + data, err := os.ReadFile(path) + if err == nil { + var f keypairFile + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parse relay key file: %w", err) + } + pubBytes, err := hex.DecodeString(f.Pub) + if err != nil || len(pubBytes) != 32 { + return nil, fmt.Errorf("invalid relay pub key in %s", path) + } + privBytes, err := hex.DecodeString(f.Priv) + if err != nil || len(privBytes) != 32 { + return nil, fmt.Errorf("invalid relay priv key in %s", path) + } + kp := &KeyPair{} + copy(kp.Pub[:], pubBytes) + copy(kp.Priv[:], privBytes) + return kp, nil + } + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read relay key file: %w", err) + } + + // Generate new keypair and persist. + kp, err := GenerateKeyPair() + if err != nil { + return nil, err + } + f := keypairFile{ + Pub: kp.PubHex(), + Priv: hex.EncodeToString(kp.Priv[:]), + } + out, _ := json.MarshalIndent(f, "", " ") + if err := os.WriteFile(path, out, 0600); err != nil { + return nil, fmt.Errorf("write relay key file: %w", err) + } + return kp, nil +} diff --git a/relay/mailbox.go b/relay/mailbox.go new file mode 100644 index 0000000..755cd4e --- /dev/null +++ b/relay/mailbox.go @@ -0,0 +1,265 @@ +package relay + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + badger "github.com/dgraph-io/badger/v4" +) + +const ( + // mailboxTTL is how long undelivered envelopes are retained. + mailboxTTL = 7 * 24 * time.Hour + + // mailboxPrefix is the BadgerDB key prefix for stored envelopes. + // Key format: mail::: + mailboxPrefix = "mail:" + + // MailboxMaxLimit caps the number of envelopes returned per single query. + MailboxMaxLimit = 200 + + // MailboxPerRecipientCap is the maximum number of envelopes stored per + // recipient key. When the cap is reached, the oldest envelope is evicted + // before the new one is written (sliding window, FIFO). + // At 64 KB max per envelope, one recipient occupies at most ~32 MB. + MailboxPerRecipientCap = 500 + + // MailboxMaxEnvelopeSize is the maximum allowed ciphertext length in bytes. + // Rejects oversized envelopes before writing to disk. + MailboxMaxEnvelopeSize = 64 * 1024 // 64 KB +) + +// ErrEnvelopeTooLarge is returned by Store when the envelope exceeds the size limit. +var ErrEnvelopeTooLarge = errors.New("envelope ciphertext exceeds maximum allowed size") + +// ErrMailboxFull is never returned externally (oldest entry is evicted instead), +// but kept as a sentinel for internal logic. +var errMailboxFull = errors.New("recipient mailbox is at capacity") + +// Mailbox is a BadgerDB-backed store for relay envelopes awaiting pickup. +// Every received envelope is stored with a 7-day TTL regardless of whether +// the recipient is currently online. Recipients poll GET /relay/inbox to fetch +// and DELETE /relay/inbox/{id} to acknowledge delivery. +// +// Anti-spam guarantees: +// - Envelopes larger than MailboxMaxEnvelopeSize (64 KB) are rejected. +// - At most MailboxPerRecipientCap (500) envelopes per recipient are stored; +// when the cap is hit the oldest entry is silently evicted (FIFO). +// - All entries expire automatically after 7 days (BadgerDB TTL). +// +// Messages are stored encrypted — the relay cannot read their contents. +type Mailbox struct { + db *badger.DB + + // onStore, if set, is invoked after every successful Store. Used by the + // node to push a WebSocket `inbox` event to subscribers of the + // recipient's x25519 pubkey so the mobile client stops polling + // /relay/inbox every 3 seconds. + // + // The callback MUST NOT block — it runs on the writer goroutine. Long + // work should be fanned out to a goroutine by the callback itself. + onStore func(*Envelope) +} + +// SetOnStore registers a post-Store hook. Pass nil to clear. Safe to call +// before accepting traffic (wired once at node startup in main.go). +func (m *Mailbox) SetOnStore(cb func(*Envelope)) { + m.onStore = cb +} + +// NewMailbox creates a Mailbox backed by the given BadgerDB instance. +func NewMailbox(db *badger.DB) *Mailbox { + return &Mailbox{db: db} +} + +// OpenMailbox opens (or creates) a dedicated BadgerDB at dbPath for the mailbox. +// +// Storage tuning matches blockchain/chain.NewChain — 64 MiB vlog files +// (instead of 1 GiB default) so GC can actually shrink the DB, and single +// version retention since envelopes are either present or deleted. +func OpenMailbox(dbPath string) (*Mailbox, error) { + opts := badger.DefaultOptions(dbPath). + WithLogger(nil). + WithValueLogFileSize(64 << 20). + WithNumVersionsToKeep(1). + WithCompactL0OnClose(true) + db, err := badger.Open(opts) + if err != nil { + return nil, fmt.Errorf("open mailbox db: %w", err) + } + return &Mailbox{db: db}, nil +} + +// Close closes the underlying database. +func (m *Mailbox) Close() error { return m.db.Close() } + +// Store persists an envelope with a 7-day TTL. +// +// Anti-spam checks (in order): +// 1. Ciphertext > MailboxMaxEnvelopeSize → returns ErrEnvelopeTooLarge. +// 2. Duplicate envelope ID → silently overwritten (idempotent). +// 3. Recipient already has MailboxPerRecipientCap entries → oldest evicted first. +func (m *Mailbox) Store(env *Envelope) error { + if len(env.Ciphertext) > MailboxMaxEnvelopeSize { + return ErrEnvelopeTooLarge + } + + key := mailboxKey(env.RecipientPub, env.SentAt, env.ID) + val, err := json.Marshal(env) + if err != nil { + return fmt.Errorf("marshal envelope: %w", err) + } + + // Track whether this was a fresh insert (vs. duplicate) so we can skip + // firing the WS hook for idempotent resubmits — otherwise a misbehaving + // sender could amplify events by spamming the same envelope ID. + fresh := false + err = m.db.Update(func(txn *badger.Txn) error { + // Check if this exact envelope is already stored (idempotent). + if _, err := txn.Get([]byte(key)); err == nil { + return nil // already present, no-op + } + + // Count existing envelopes for this recipient and collect the oldest key. + prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, env.RecipientPub)) + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + opts.Prefix = prefix + + var count int + var oldestKey []byte + it := txn.NewIterator(opts) + for it.Rewind(); it.Valid(); it.Next() { + if count == 0 { + oldestKey = it.Item().KeyCopy(nil) // first = oldest (sorted by sentAt) + } + count++ + } + it.Close() + + // Evict the oldest envelope if cap is reached. + if count >= MailboxPerRecipientCap && oldestKey != nil { + if err := txn.Delete(oldestKey); err != nil { + return fmt.Errorf("evict oldest envelope: %w", err) + } + } + + e := badger.NewEntry([]byte(key), val).WithTTL(mailboxTTL) + if err := txn.SetEntry(e); err != nil { + return err + } + fresh = true + return nil + }) + if err == nil && fresh && m.onStore != nil { + m.onStore(env) + } + return err +} + +// List returns up to limit envelopes for recipientPubHex, ordered oldest-first. +// Pass since > 0 to skip envelopes with SentAt < since (unix timestamp). +func (m *Mailbox) List(recipientPubHex string, since int64, limit int) ([]*Envelope, error) { + if limit <= 0 || limit > MailboxMaxLimit { + limit = MailboxMaxLimit + } + prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex)) + var out []*Envelope + + err := m.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + + for it.Rewind(); it.Valid() && len(out) < limit; it.Next() { + if err := it.Item().Value(func(val []byte) error { + var env Envelope + if err := json.Unmarshal(val, &env); err != nil { + return nil // skip corrupt entries + } + if since > 0 && env.SentAt < since { + return nil + } + out = append(out, &env) + return nil + }); err != nil { + return err + } + } + return nil + }) + return out, err +} + +// Delete removes an envelope by ID. +// It scans the recipient's prefix to locate the full key (sentAt is not known by caller). +// Returns nil if the envelope is not found (already expired or never stored). +func (m *Mailbox) Delete(recipientPubHex, envelopeID string) error { + prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex)) + var found []byte + + err := m.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + + suffix := ":" + envelopeID + for it.Rewind(); it.Valid(); it.Next() { + key := it.Item().KeyCopy(nil) + if strings.HasSuffix(string(key), suffix) { + found = key + return nil + } + } + return nil + }) + if err != nil || found == nil { + return err + } + + return m.db.Update(func(txn *badger.Txn) error { + return txn.Delete(found) + }) +} + +// Count returns the number of stored envelopes for a recipient. +func (m *Mailbox) Count(recipientPubHex string) (int, error) { + prefix := []byte(fmt.Sprintf("%s%s:", mailboxPrefix, recipientPubHex)) + count := 0 + err := m.db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + opts.Prefix = prefix + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + count++ + } + return nil + }) + return count, err +} + +// RunGC periodically runs BadgerDB value log garbage collection. +// Call in a goroutine — blocks until cancelled via channel close or process exit. +func (m *Mailbox) RunGC() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + for m.db.RunValueLogGC(0.5) == nil { + // drain until nothing left to collect + } + } +} + +func mailboxKey(recipientPubHex string, sentAt int64, envelopeID string) string { + // Zero-padded sentAt keeps lexicographic order == chronological order. + // Oldest entry = first key in iterator — used for FIFO eviction. + return fmt.Sprintf("%s%s:%020d:%s", mailboxPrefix, recipientPubHex, sentAt, envelopeID) +} diff --git a/relay/router.go b/relay/router.go new file mode 100644 index 0000000..12aaddb --- /dev/null +++ b/relay/router.go @@ -0,0 +1,227 @@ +package relay + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "time" + + pubsub "github.com/libp2p/go-libp2p-pubsub" + "github.com/libp2p/go-libp2p/core/host" + + "go-blockchain/blockchain" + "go-blockchain/identity" +) + +const ( + // TopicRelay is the gossipsub topic for encrypted envelope routing. + TopicRelay = "dchain/relay/v1" +) + +// DeliverFunc is called when a message addressed to this node is decrypted. +type DeliverFunc func(envelopeID, senderEd25519PubKey string, msg []byte) + +// SubmitTxFunc submits a signed transaction to the local node's mempool. +type SubmitTxFunc func(*blockchain.Transaction) error + +// Router manages relay envelope routing for a single node. +// It subscribes to TopicRelay, decrypts messages addressed to this node, +// and submits RELAY_PROOF transactions to claim delivery fees from senders. +// +// All received envelopes (regardless of recipient) are stored in the Mailbox +// so offline recipients can pull them later via GET /relay/inbox. +type Router struct { + h host.Host + topic *pubsub.Topic + sub *pubsub.Subscription + kp *KeyPair // X25519 keypair for envelope encryption + id *identity.Identity // Ed25519 identity for signing RELAY_PROOF txs + mailbox *Mailbox // nil disables mailbox storage + onDeliver DeliverFunc + submitTx SubmitTxFunc +} + +// NewRouter creates and starts a relay Router. +// mailbox may be nil to disable offline message storage. +func NewRouter( + h host.Host, + ps *pubsub.PubSub, + kp *KeyPair, + id *identity.Identity, + mailbox *Mailbox, + onDeliver DeliverFunc, + submitTx SubmitTxFunc, +) (*Router, error) { + topic, err := ps.Join(TopicRelay) + if err != nil { + return nil, fmt.Errorf("join relay topic: %w", err) + } + sub, err := topic.Subscribe() + if err != nil { + topic.Close() + return nil, fmt.Errorf("subscribe relay topic: %w", err) + } + return &Router{ + h: h, + topic: topic, + sub: sub, + kp: kp, + id: id, + mailbox: mailbox, + onDeliver: onDeliver, + submitTx: submitTx, + }, nil +} + +// Send seals msg for recipientPub and broadcasts it on the relay topic. +// recipientPubHex is the hex X25519 public key of the recipient. +// feeUT is the delivery fee offered to the relay (0 = free delivery). +// Returns the envelope ID on success. +func (r *Router) Send(recipientPubHex string, msg []byte, feeUT uint64) (string, error) { + recipBytes, err := hex.DecodeString(recipientPubHex) + if err != nil || len(recipBytes) != 32 { + return "", fmt.Errorf("invalid recipient pub key: %s", recipientPubHex) + } + var recipPub [32]byte + copy(recipPub[:], recipBytes) + + env, err := Seal(r.kp, r.id, recipPub, msg, feeUT, time.Now().Unix()) + if err != nil { + return "", fmt.Errorf("seal envelope: %w", err) + } + data, err := json.Marshal(env) + if err != nil { + return "", err + } + if err := r.topic.Publish(context.Background(), data); err != nil { + return "", err + } + return env.ID, nil +} + +// Run processes incoming relay envelopes until ctx is cancelled. +// Envelopes addressed to this node are decrypted and acknowledged; others +// are ignored (gossipsub handles propagation automatically). +func (r *Router) Run(ctx context.Context) { + for { + m, err := r.sub.Next(ctx) + if err != nil { + return + } + if m.ReceivedFrom == r.h.ID() { + continue + } + var env Envelope + if err := json.Unmarshal(m.Data, &env); err != nil { + continue + } + + // Store every valid envelope in the mailbox for offline delivery. + // Messages are encrypted — the relay cannot read them. + // ErrEnvelopeTooLarge is silently dropped (anti-spam); other errors are logged. + if r.mailbox != nil { + if err := r.mailbox.Store(&env); err != nil { + if err == ErrEnvelopeTooLarge { + log.Printf("[relay] dropped oversized envelope %s (%d bytes ciphertext)", + env.ID, len(env.Ciphertext)) + } else { + log.Printf("[relay] mailbox store error for %s: %v", env.ID, err) + } + } + } + + if !env.IsAddressedTo(r.kp) { + continue + } + msg, err := Open(r.kp, &env) + if err != nil { + log.Printf("[relay] decryption error for envelope %s: %v", env.ID, err) + continue + } + if r.onDeliver != nil { + r.onDeliver(env.ID, env.SenderEd25519PubKey, msg) + } + if r.submitTx != nil && env.FeeUT > 0 && env.SenderEd25519PubKey != "" { + if err := r.submitRelayProof(&env); err != nil { + log.Printf("[relay] relay proof submission failed for %s: %v", env.ID, err) + } + } + } +} + +// Broadcast stores env in the mailbox and publishes it on the relay gossipsub topic. +// Used by the HTTP API so light clients can send pre-sealed envelopes without +// needing a direct libp2p connection. +func (r *Router) Broadcast(env *Envelope) error { + if r.mailbox != nil { + if err := r.mailbox.Store(env); err != nil && err != ErrEnvelopeTooLarge { + log.Printf("[relay] broadcast mailbox store error %s: %v", env.ID, err) + } + } + data, err := json.Marshal(env) + if err != nil { + return err + } + return r.topic.Publish(context.Background(), data) +} + +// RelayPubHex returns the hex X25519 public key for this node's relay keypair. +func (r *Router) RelayPubHex() string { + return r.kp.PubHex() +} + +// submitRelayProof builds and submits a RELAY_PROOF tx to claim the delivery fee. +func (r *Router) submitRelayProof(env *Envelope) error { + envHash := Hash(env) + relayPubKey := r.id.PubKeyHex() + + // Recipient signs envelope hash — proves the message was actually decrypted. + recipientSig := r.id.Sign(envHash) + + payload := blockchain.RelayProofPayload{ + EnvelopeID: env.ID, + EnvelopeHash: envHash, + SenderPubKey: env.SenderEd25519PubKey, + FeeUT: env.FeeUT, + FeeSig: env.FeeSig, + RelayPubKey: relayPubKey, + DeliveredAt: time.Now().Unix(), + RecipientSig: recipientSig, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + + idBytes := sha256.Sum256(append([]byte(relayPubKey), []byte(env.ID)...)) + now := time.Now().UTC() + tx := &blockchain.Transaction{ + ID: hex.EncodeToString(idBytes[:16]), + Type: blockchain.EventRelayProof, + From: relayPubKey, + Fee: blockchain.MinFee, + Payload: payloadBytes, + Timestamp: now, + } + tx.Signature = r.id.Sign(txSignBytes(tx)) + return r.submitTx(tx) +} + +// txSignBytes returns the canonical bytes that are signed for a transaction, +// matching the format used in identity.txSignBytes. +func txSignBytes(tx *blockchain.Transaction) []byte { + data, _ := json.Marshal(struct { + ID string `json:"id"` + Type blockchain.EventType `json:"type"` + From string `json:"from"` + To string `json:"to"` + Amount uint64 `json:"amount"` + Fee uint64 `json:"fee"` + Payload []byte `json:"payload"` + Timestamp time.Time `json:"timestamp"` + }{tx.ID, tx.Type, tx.From, tx.To, tx.Amount, tx.Fee, tx.Payload, tx.Timestamp}) + return data +} diff --git a/scripts/deploy_contracts.sh b/scripts/deploy_contracts.sh new file mode 100644 index 0000000..510f60f --- /dev/null +++ b/scripts/deploy_contracts.sh @@ -0,0 +1,182 @@ +#!/bin/sh +# deploy_contracts.sh — деплой всех 4 production-контрактов из genesis-кошелька. +# +# Запуск: +# docker compose --profile deploy run --rm deploy +# +# После деплоя contract_id'ы выводятся в stdout и сохраняются в /tmp/contracts.env. +# Контракты видны в Explorer: http://localhost:8081/contracts + +set -e + +# ── Конфигурация нод (через backbone IP, без DNS) ───────────────────────────── +NODE1_URL="${NODE1_URL:-http://172.30.0.11:8080}" +NODE2_URL="${NODE2_URL:-http://172.30.0.12:8080}" +NODE3_URL="${NODE3_URL:-http://172.30.0.13:8080}" + +# Основная нода для деплоя (genesis-кошелёк на node1) +NODE="$NODE1_URL" +GENESIS_KEY="/keys/node1.json" + +BOLD='\033[1m' +CYAN='\033[1;36m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +RESET='\033[0m' + +step() { printf "\n${CYAN}▶ %s${RESET}\n" "$*"; } +ok() { printf " ${GREEN}✓${RESET} %s\n" "$*"; } +fail() { printf " ${RED}✗${RESET} %s\n" "$*"; exit 1; } +info() { printf " %s\n" "$*"; } + +# ── Ожидание готовности ноды ────────────────────────────────────────────────── + +wait_node() { + local name="$1" url="$2" + step "Ожидание готовности $name ($url)..." + i=0 + while [ $i -lt 90 ]; do + if wget -qO /dev/null "$url/api/netstats" 2>/dev/null; then + ok "$name доступен"; break + fi + printf "."; sleep 1; i=$((i+1)) + done + [ $i -lt 90 ] || fail "$name недоступен после 90 секунд" +} + +wait_node "node1" "$NODE1_URL" +wait_node "node2" "$NODE2_URL" +wait_node "node3" "$NODE3_URL" + +printf " Ждём genesis block на node1" +i=0 +while [ $i -lt 60 ]; do + if wget -qO- "$NODE/api/netstats" 2>/dev/null | grep -q '"total_blocks": *[1-9]'; then + printf " — готово.\n"; break + fi + printf "."; sleep 1; i=$((i+1)) +done +[ $i -lt 60 ] || fail "Genesis block не появился" + +# ── Хелпер: деплой одного контракта ────────────────────────────────────────── + +deploy_contract() { + local name="$1" wasm="$2" abi="$3" + step "Деплой $name" + OUT=$(client deploy-contract \ + --key "$GENESIS_KEY" \ + --wasm "$wasm" \ + --abi "$abi" \ + --node "$NODE" 2>&1) + printf '%s\n' "$OUT" + + CID=$(printf '%s' "$OUT" | grep 'contract_id:' | awk '{print $NF}') + [ -n "$CID" ] || fail "Не удалось получить contract_id для $name" + + # Ждём подтверждения on-chain + printf " Ждём подтверждения" + i=0 + while [ $i -lt 40 ]; do + if wget -qO- "$NODE/api/contracts/$CID" 2>/dev/null | grep -q '"contract_id"'; then + printf " — задеплоен.\n"; break + fi + printf "."; sleep 1; i=$((i+1)) + done + [ $i -lt 40 ] || fail "Timeout ожидания контракта $name" + + ok "$name contract_id: $CID" + echo "$CID" +} + +# ── Инициализация контракта (вызов init) ────────────────────────────────────── + +init_contract() { + local name="$1" cid="$2" + step "Инициализация $name (вызов init)" + client call-contract \ + --key "$GENESIS_KEY" \ + --contract "$cid" \ + --method init \ + --gas 50000 \ + --node "$NODE" 2>&1 | grep -v '^$' || true + sleep 2 + ok "$name инициализирован" +} + +# ── Деплой всех контрактов ──────────────────────────────────────────────────── + +printf "\n${YELLOW}══════════════════════════════════════════════════${RESET}\n" +printf "${BOLD} DChain — деплой production-контрактов${RESET}\n" +printf "${YELLOW}══════════════════════════════════════════════════${RESET}\n" + +UR_ID=$(deploy_contract "username_registry" \ + "/keys/username_registry.wasm" \ + "/keys/username_registry_abi.json") + +GOV_ID=$(deploy_contract "governance" \ + "/keys/governance.wasm" \ + "/keys/governance_abi.json") + +AUC_ID=$(deploy_contract "auction" \ + "/keys/auction.wasm" \ + "/keys/auction_abi.json") + +ESC_ID=$(deploy_contract "escrow" \ + "/keys/escrow.wasm" \ + "/keys/escrow_abi.json") + +# Инициализируем контракты с admin-ролью +init_contract "governance" "$GOV_ID" +init_contract "escrow" "$ESC_ID" + +# ── Линковка governance со всеми нодами (runtime, без перезапуска) ──────────── + +link_governance() { + local name="$1" url="$2" + step "Линковка governance с $name ($url)" + RESP=$(wget -qO- --post-data="{\"governance\":\"$GOV_ID\"}" \ + --header="Content-Type: application/json" \ + "$url/api/governance/link" 2>/dev/null || true) + if printf '%s' "$RESP" | grep -q '"ok"'; then + ok "governance привязан — gas_price управляется on-chain" + else + info "нода недоступна или уже настроена: $RESP" + fi +} + +link_governance "node1" "$NODE1_URL" +link_governance "node2" "$NODE2_URL" +link_governance "node3" "$NODE3_URL" + +# ── Итоговый вывод ──────────────────────────────────────────────────────────── + +printf "\n${YELLOW}══════════════════════════════════════════════════${RESET}\n" +printf "${BOLD} Все контракты задеплоены!${RESET}\n" +printf "${YELLOW}══════════════════════════════════════════════════${RESET}\n\n" + +ok "username_registry : $UR_ID" +ok "governance : $GOV_ID" +ok "auction : $AUC_ID" +ok "escrow : $ESC_ID" + +printf "\n${BOLD}Explorer:${RESET}\n" +info "node1 → http://localhost:8081/contracts" +info "node2 → http://localhost:8082/contracts" +info "node3 → http://localhost:8083/contracts" +printf "\n" +info "http://localhost:8081/contract?id=$UR_ID (username_registry)" +info "http://localhost:8081/contract?id=$GOV_ID (governance)" +info "http://localhost:8081/contract?id=$AUC_ID (auction)" +info "http://localhost:8081/contract?id=$ESC_ID (escrow)" + +# Сохраняем ID для последующего использования +cat > /tmp/contracts.env << EOF +USERNAME_REGISTRY=$UR_ID +GOVERNANCE=$GOV_ID +AUCTION=$AUC_ID +ESCROW=$ESC_ID +EOF +printf "\n" +ok "ID сохранены в /tmp/contracts.env" +echo diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 0000000..70afc50 --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,28 @@ +# testdata — dev-cluster identities + +These three JSON files are **hard-coded test keys** used by the 3-node dev +cluster in the repository-root `docker-compose.yml`. They exist ONLY so that +running `docker compose up --build -d` gives every developer the same +deterministic network (same pubkeys, same peer IDs, same genesis validator +set) — making screenshots, tutorials, and loadtest fixtures reproducible. + +**Do NOT use these keys for anything that holds real value.** The private +keys are in the repository history, visible to everyone who can clone. + +## When you need real keys + +- Single-node prod deploy: see `deploy/single/README.md` — generate a fresh + key with `docker run --entrypoint /usr/local/bin/client dchain-node-slim + keygen --out node.json` and store it outside the repo. +- Multi-node prod cluster: `deploy/prod/README.md`. + +## Files + +| File | pub_key | peer ID | +|---------------|--------------------------------------------------------------------|----------------------------------------------------------| +| `node1.json` | `26018d40e40514f38f799eee403f62da98cb5ac936e29049629f1873cbcb4070` | `12D3KooWCNj2ugnjqoJFPdRuhGZHvGTbEiTMmHDimfsxmGYcjGo9` | +| `node2.json` | `bf3628d1a10fcf5a90d2cb31f387c8d1f2dac6a2c54c736c27d5ea04af9696a2` | `12D3KooWNgmwMbaw5K7vDbGxb9zvcF8gur5GWXGoFEVfKzSNc9bf` | +| `node3.json` | `6316e7427654cd2e300033c5e13b6182d595ec2c63bc8396b74183296112510c` | `12D3KooWGVAnaq1EgH1dN49fQWvw1z71R5bZj7UmPkUeQkW4783V` | + +The baked-in WASM contracts under `../contracts/*` are also copied into the +dev Docker image at build time — they don't need per-node identities. diff --git a/testdata/node1.json b/testdata/node1.json new file mode 100644 index 0000000..a8fd053 --- /dev/null +++ b/testdata/node1.json @@ -0,0 +1,6 @@ +{ + "pub_key": "26018d40e40514f38f799eee403f62da98cb5ac936e29049629f1873cbcb4070", + "priv_key": "16aba1d2ffe7b1d0603ec5fbc2a48a9894fe61019af91b39217a88fc1bbfa5ae26018d40e40514f38f799eee403f62da98cb5ac936e29049629f1873cbcb4070", + "x25519_pub": "baada10a9c49ea7395cc8b39393cf9b292d255626eff708f0ba2c25d1aa8177d", + "x25519_priv": "a814c191256c362fd0874cd00fae00e3846a3451cc7093b0e080acc9bf50da56" +} diff --git a/testdata/node2.json b/testdata/node2.json new file mode 100644 index 0000000..de74ca9 --- /dev/null +++ b/testdata/node2.json @@ -0,0 +1,6 @@ +{ + "pub_key": "bf3628d1a10fcf5a90d2cb31f387c8d1f2dac6a2c54c736c27d5ea04af9696a2", + "priv_key": "8289b3e36db67fe78bc7bb359590536188b084c4b302f6021bd59e7db21e543abf3628d1a10fcf5a90d2cb31f387c8d1f2dac6a2c54c736c27d5ea04af9696a2", + "x25519_pub": "9b7c5f43c79217dff8413ab2116b84b19b1e05606c143a82885609e08ac28579", + "x25519_priv": "b04fd2c99dda64e70601de5c92d05afe560733665867f911dd725ec674137a7a" +} diff --git a/testdata/node3.json b/testdata/node3.json new file mode 100644 index 0000000..8ab8c25 --- /dev/null +++ b/testdata/node3.json @@ -0,0 +1,6 @@ +{ + "pub_key": "6316e7427654cd2e300033c5e13b6182d595ec2c63bc8396b74183296112510c", + "priv_key": "49b357907960d5c11ddcf3d17c2e1a73971134974a759a8c5fb592a704b3e3f46316e7427654cd2e300033c5e13b6182d595ec2c63bc8396b74183296112510c", + "x25519_pub": "4a01c8ed427adbbb9aa799c3916921c8642086c6f3831d442123dc285bc9c04c", + "x25519_priv": "9073c8946ef5d520376a4c23bdd89f7bed4fa5ef7d69cbdc593e020a3a59ad78" +} diff --git a/vm/abi.go b/vm/abi.go new file mode 100644 index 0000000..0b871ef --- /dev/null +++ b/vm/abi.go @@ -0,0 +1,71 @@ +package vm + +import ( + "encoding/json" + "fmt" +) + +// ABI describes the callable interface of a deployed contract. +type ABI struct { + Methods []ABIMethod `json:"methods"` +} + +// ABIMethod describes a single callable method. +type ABIMethod struct { + Name string `json:"name"` + Args []ABIArg `json:"args"` // may be nil / empty for zero-arg methods +} + +// ABIArg describes one parameter of a method. +type ABIArg struct { + Name string `json:"name"` // e.g. "amount" + Type string `json:"type,omitempty"` // e.g. "uint64", "string", "bytes" +} + +// ParseABI deserializes an ABI from JSON. +func ParseABI(jsonStr string) (*ABI, error) { + var a ABI + if err := json.Unmarshal([]byte(jsonStr), &a); err != nil { + return nil, fmt.Errorf("invalid ABI JSON: %w", err) + } + return &a, nil +} + +// HasMethod returns true if the ABI declares the named method. +func (a *ABI) HasMethod(name string) bool { + for _, m := range a.Methods { + if m.Name == name { + return true + } + } + return false +} + +// Validate checks that method exists in the ABI and args_json has the right +// number of elements. argsJSON may be empty ("" or "[]") for zero-arg methods. +func (a *ABI) Validate(method string, argsJSON []byte) error { + var target *ABIMethod + for i := range a.Methods { + if a.Methods[i].Name == method { + target = &a.Methods[i] + break + } + } + if target == nil { + return fmt.Errorf("method %q not found in ABI", method) + } + if len(target.Args) == 0 { + return nil // no args expected — nothing to validate + } + if len(argsJSON) > 0 { + var args []any + if err := json.Unmarshal(argsJSON, &args); err != nil { + return fmt.Errorf("args_json is not a JSON array: %w", err) + } + if len(args) != len(target.Args) { + return fmt.Errorf("method %q expects %d args, got %d", + method, len(target.Args), len(args)) + } + } + return nil +} diff --git a/vm/gas.go b/vm/gas.go new file mode 100644 index 0000000..af17a56 --- /dev/null +++ b/vm/gas.go @@ -0,0 +1,111 @@ +package vm + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" + + "go-blockchain/blockchain" +) + +// ErrOutOfGas is returned when a contract call exhausts its gas limit. +// It wraps blockchain.ErrTxFailed so the call is skipped rather than +// aborting the entire block. +var ErrOutOfGas = fmt.Errorf("%w: out of gas", blockchain.ErrTxFailed) + +// gasKey is the context key used to pass the gas counter into host functions. +type gasKey struct{} + +// gasCounter holds a mutable gas counter accessible through a context value. +type gasCounter struct { + used atomic.Uint64 + limit uint64 +} + +// withGasCounter attaches a new gas counter to ctx. +func withGasCounter(ctx context.Context, limit uint64) (context.Context, *gasCounter) { + gc := &gasCounter{limit: limit} + return context.WithValue(ctx, gasKey{}, gc), gc +} + +// gasFromContext retrieves the gas counter from ctx; panics if not present. +func gasFromContext(ctx context.Context) *gasCounter { + gc, _ := ctx.Value(gasKey{}).(*gasCounter) + return gc +} + +// charge attempts to consume n gas units. Returns ErrOutOfGas if the limit is exceeded. +func (gc *gasCounter) charge(n uint64) error { + if gc == nil { + return nil + } + newUsed := gc.used.Add(n) + if newUsed > gc.limit { + return ErrOutOfGas + } + return nil +} + +// Used returns total gas consumed so far. +func (gc *gasCounter) Used() uint64 { + if gc == nil { + return 0 + } + return gc.used.Load() +} + +// Remaining returns gas budget minus gas used. Returns 0 when exhausted. +func (gc *gasCounter) Remaining() uint64 { + if gc == nil { + return 0 + } + used := gc.used.Load() + if used >= gc.limit { + return 0 + } + return gc.limit - used +} + +// gasListenerFactory is a wazero FunctionListenerFactory that charges gas on +// every WASM function call. Each function call costs gasPerCall units. +// True instruction-level metering would require bytecode instrumentation; +// call-level metering is a pragmatic approximation that prevents runaway contracts. +const gasPerCall uint64 = 100 + +type gasListenerFactory struct{} + +func (gasListenerFactory) NewFunctionListener(def api.FunctionDefinition) experimental.FunctionListener { + return gasListener{} +} + +type gasListener struct{} + +func (gasListener) Before(ctx context.Context, _ api.Module, _ api.FunctionDefinition, _ []uint64, _ experimental.StackIterator) { + gc := gasFromContext(ctx) + if gc != nil { + // Ignore error here — we can't abort from Before. + // isOutOfGas() is checked after Call() returns. + _ = gc.charge(gasPerCall) + } +} + +func (gasListener) After(_ context.Context, _ api.Module, _ api.FunctionDefinition, _ []uint64) {} +func (gasListener) Abort(_ context.Context, _ api.Module, _ api.FunctionDefinition, _ error) {} + +// isOutOfGas reports whether err indicates gas exhaustion. +func isOutOfGas(gc *gasCounter) bool { + if gc == nil { + return false + } + return gc.Used() > gc.limit +} + +// ensure gasListenerFactory satisfies the interface at compile time. +var _ experimental.FunctionListenerFactory = gasListenerFactory{} + +// ensure errors chain correctly. +var _ = errors.Is(ErrOutOfGas, blockchain.ErrTxFailed) diff --git a/vm/host.go b/vm/host.go new file mode 100644 index 0000000..1a19341 --- /dev/null +++ b/vm/host.go @@ -0,0 +1,382 @@ +package vm + +import ( + "context" + "encoding/binary" + "encoding/json" + "log" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + + "go-blockchain/blockchain" +) + +// registerHostModule builds and instantiates the "env" host module. +// argsJSON holds the raw JSON args_json bytes from the CALL_CONTRACT payload; +// they are exposed to the WASM contract via get_args / get_arg_str / get_arg_u64. +// +// Host functions available to WASM contracts: +// +// env.get_state(keyPtr, keyLen, dstPtr, dstLen i32) → written i32 +// env.get_state_len(keyPtr, keyLen i32) → valLen i32 +// env.set_state(keyPtr, keyLen, valPtr, valLen i32) +// env.get_balance(pubPtr, pubLen i32) → balance i64 +// env.transfer(fromPtr, fromLen, toPtr, toLen i32, amount i64) → errCode i32 +// env.get_caller(bufPtr, bufLen i32) → written i32 +// env.get_block_height() → height i64 +// env.get_contract_treasury(bufPtr, bufLen i32) → written i32 +// env.log(msgPtr, msgLen i32) +// env.put_u64(keyPtr, keyLen i32, val i64) +// env.get_u64(keyPtr, keyLen i32) → val i64 +// env.get_args(dstPtr, dstLen i32) → written i32 +// env.get_arg_str(idx, dstPtr, dstLen i32) → written i32 +// env.get_arg_u64(idx i32) → val i64 +// env.call_contract(cidPtr, cidLen, mthPtr, mthLen, argPtr, argLen i32) → errCode i32 +func registerHostModule(ctx context.Context, rt wazero.Runtime, env blockchain.VMHostEnv, argsJSON []byte) (api.Closer, error) { + b := rt.NewHostModuleBuilder("env") + + // --- get_state_len(keyPtr i32, keyLen i32) → valLen i32 --- + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + keyPtr := api.DecodeU32(stack[0]) + keyLen := api.DecodeU32(stack[1]) + key, ok := m.Memory().Read(keyPtr, keyLen) + if !ok { + stack[0] = 0 + return + } + val, _ := env.GetState(key) + stack[0] = api.EncodeU32(uint32(len(val))) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI32}). + Export("get_state_len") + + // --- get_state(keyPtr i32, keyLen i32, dstPtr i32, dstLen i32) → written i32 --- + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + keyPtr := api.DecodeU32(stack[0]) + keyLen := api.DecodeU32(stack[1]) + dstPtr := api.DecodeU32(stack[2]) + dstLen := api.DecodeU32(stack[3]) + key, ok := m.Memory().Read(keyPtr, keyLen) + if !ok { + stack[0] = 0 + return + } + val, _ := env.GetState(key) + n := uint32(len(val)) + if n > dstLen { + n = dstLen + } + if n > 0 { + m.Memory().Write(dstPtr, val[:n]) + } + stack[0] = api.EncodeU32(n) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI32}). + Export("get_state") + + // --- set_state(keyPtr i32, keyLen i32, valPtr i32, valLen i32) --- + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + keyPtr := api.DecodeU32(stack[0]) + keyLen := api.DecodeU32(stack[1]) + valPtr := api.DecodeU32(stack[2]) + valLen := api.DecodeU32(stack[3]) + key, okK := m.Memory().Read(keyPtr, keyLen) + val, okV := m.Memory().Read(valPtr, valLen) + if !okK || !okV { + return + } + // Copy slices — WASM memory may be invalidated after the call returns. + keyCopy := make([]byte, len(key)) + copy(keyCopy, key) + valCopy := make([]byte, len(val)) + copy(valCopy, val) + _ = env.SetState(keyCopy, valCopy) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{}). + Export("set_state") + + // --- get_balance(pubPtr i32, pubLen i32) → balance i64 --- + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + pubPtr := api.DecodeU32(stack[0]) + pubLen := api.DecodeU32(stack[1]) + pub, ok := m.Memory().Read(pubPtr, pubLen) + if !ok { + stack[0] = 0 + return + } + bal, _ := env.GetBalance(string(pub)) + stack[0] = api.EncodeI64(int64(bal)) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI64}). + Export("get_balance") + + // --- transfer(fromPtr, fromLen, toPtr, toLen i32, amount i64) → errCode i32 --- + // Returns 0 on success, 1 on failure (insufficient balance or bad args). + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + fromPtr := api.DecodeU32(stack[0]) + fromLen := api.DecodeU32(stack[1]) + toPtr := api.DecodeU32(stack[2]) + toLen := api.DecodeU32(stack[3]) + amount := uint64(int64(stack[4])) + from, okF := m.Memory().Read(fromPtr, fromLen) + to, okT := m.Memory().Read(toPtr, toLen) + if !okF || !okT { + stack[0] = api.EncodeU32(1) + return + } + if err := env.Transfer(string(from), string(to), amount); err != nil { + stack[0] = api.EncodeU32(1) + return + } + stack[0] = api.EncodeU32(0) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI64}, []api.ValueType{api.ValueTypeI32}). + Export("transfer") + + // --- get_caller(bufPtr i32, bufLen i32) → written i32 --- + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + bufPtr := api.DecodeU32(stack[0]) + bufLen := api.DecodeU32(stack[1]) + caller := env.GetCaller() + n := uint32(len(caller)) + if n > bufLen { + n = bufLen + } + if n > 0 { + m.Memory().Write(bufPtr, []byte(caller[:n])) + } + stack[0] = api.EncodeU32(n) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI32}). + Export("get_caller") + + // --- get_block_height() → height i64 --- + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + stack[0] = api.EncodeI64(int64(env.GetBlockHeight())) + }), []api.ValueType{}, []api.ValueType{api.ValueTypeI64}). + Export("get_block_height") + + // --- log(msgPtr i32, msgLen i32) --- + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + msgPtr := api.DecodeU32(stack[0]) + msgLen := api.DecodeU32(stack[1]) + msg, ok := m.Memory().Read(msgPtr, msgLen) + if !ok { + return + } + env.Log(string(msg)) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{}). + Export("log") + + // --- put_u64(keyPtr, keyLen i32, val i64) --- + // Convenience: stores a uint64 as 8-byte big-endian. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + keyPtr := api.DecodeU32(stack[0]) + keyLen := api.DecodeU32(stack[1]) + val := uint64(int64(stack[2])) + key, ok := m.Memory().Read(keyPtr, keyLen) + if !ok { + return + } + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, val) + keyCopy := make([]byte, len(key)) + copy(keyCopy, key) + _ = env.SetState(keyCopy, buf) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI64}, []api.ValueType{}). + Export("put_u64") + + // --- get_u64(keyPtr, keyLen i32) → val i64 --- + // Reads an 8-byte big-endian uint64 stored by put_u64. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + keyPtr := api.DecodeU32(stack[0]) + keyLen := api.DecodeU32(stack[1]) + key, ok := m.Memory().Read(keyPtr, keyLen) + if !ok { + stack[0] = 0 + return + } + val, _ := env.GetState(key) + if len(val) < 8 { + stack[0] = 0 + return + } + stack[0] = api.EncodeI64(int64(binary.BigEndian.Uint64(val[:8]))) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI64}). + Export("get_u64") + + // ── Argument accessors ──────────────────────────────────────────────────── + // argsJSON is the JSON array from CallContractPayload.ArgsJSON. + // These functions let contracts read typed call arguments at runtime. + + // Lazily parse argsJSON once and cache the result. + var parsedArgs []json.RawMessage + if len(argsJSON) > 0 { + _ = json.Unmarshal(argsJSON, &parsedArgs) + } + + // --- get_args(dstPtr i32, dstLen i32) → written i32 --- + // Copies the raw args_json bytes into WASM memory. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + dstPtr := api.DecodeU32(stack[0]) + dstLen := api.DecodeU32(stack[1]) + n := uint32(len(argsJSON)) + if n > dstLen { + n = dstLen + } + if n > 0 { + m.Memory().Write(dstPtr, argsJSON[:n]) + } + stack[0] = api.EncodeU32(n) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI32}). + Export("get_args") + + // --- get_arg_str(idx i32, dstPtr i32, dstLen i32) → written i32 --- + // Parses args_json as a JSON array, reads the idx-th element as a string, + // copies the UTF-8 bytes (without quotes) into WASM memory. + // Returns 0 if idx is out of range or the element is not a JSON string. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + idx := api.DecodeU32(stack[0]) + dstPtr := api.DecodeU32(stack[1]) + dstLen := api.DecodeU32(stack[2]) + if int(idx) >= len(parsedArgs) { + stack[0] = 0 + return + } + var s string + if err := json.Unmarshal(parsedArgs[idx], &s); err != nil { + stack[0] = 0 + return + } + n := uint32(len(s)) + if n > dstLen { + n = dstLen + } + if n > 0 { + m.Memory().Write(dstPtr, []byte(s[:n])) + } + stack[0] = api.EncodeU32(n) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI32}). + Export("get_arg_str") + + // --- get_arg_u64(idx i32) → val i64 --- + // Parses args_json as a JSON array, reads the idx-th element as a uint64. + // Returns 0 if idx is out of range or the element is not a JSON number. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + idx := api.DecodeU32(stack[0]) + if int(idx) >= len(parsedArgs) { + stack[0] = 0 + return + } + var n uint64 + if err := json.Unmarshal(parsedArgs[idx], &n); err != nil { + stack[0] = 0 + return + } + stack[0] = api.EncodeI64(int64(n)) + }), []api.ValueType{api.ValueTypeI32}, []api.ValueType{api.ValueTypeI64}). + Export("get_arg_u64") + + // --- get_contract_treasury(bufPtr i32, bufLen i32) → written i32 --- + // Returns the contract's ownerless treasury address as a 64-char hex string. + // Derived as hex(sha256(contractID + ":treasury")); no private key exists. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + bufPtr := api.DecodeU32(stack[0]) + bufLen := api.DecodeU32(stack[1]) + treasury := env.GetContractTreasury() + n := uint32(len(treasury)) + if n > bufLen { + n = bufLen + } + if n > 0 { + m.Memory().Write(bufPtr, []byte(treasury[:n])) + } + stack[0] = api.EncodeU32(n) + }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI32}). + Export("get_contract_treasury") + + // --- call_contract(cidPtr, cidLen, methodPtr, methodLen, argsPtr, argsLen i32) → i32 --- + // Calls a method on another deployed contract. Returns 0 on success, 1 on error. + // The sub-call's caller is set to the current contract ID. + // Gas consumed by the sub-call is charged to the parent call's gas counter. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + cidPtr := api.DecodeU32(stack[0]) + cidLen := api.DecodeU32(stack[1]) + mthPtr := api.DecodeU32(stack[2]) + mthLen := api.DecodeU32(stack[3]) + argPtr := api.DecodeU32(stack[4]) + argLen := api.DecodeU32(stack[5]) + + cid, ok1 := m.Memory().Read(cidPtr, cidLen) + mth, ok2 := m.Memory().Read(mthPtr, mthLen) + if !ok1 || !ok2 { + stack[0] = api.EncodeU32(1) + return + } + var args []byte + if argLen > 0 { + args, _ = m.Memory().Read(argPtr, argLen) + } + + // Give the sub-call whatever gas the parent has remaining. + gc := gasFromContext(ctx) + var gasLeft uint64 = 10_000 // safe fallback if no counter + if gc != nil { + gasLeft = gc.Remaining() + } + if gasLeft == 0 { + stack[0] = api.EncodeU32(1) + return + } + + gasUsed, err := env.CallContract(string(cid), string(mth), args, gasLeft) + + // Charge parent counter for what the sub-call actually consumed. + if gc != nil && gasUsed > 0 { + _ = gc.charge(gasUsed) + } + + if err != nil { + stack[0] = api.EncodeU32(1) + return + } + stack[0] = api.EncodeU32(0) + }), []api.ValueType{ + api.ValueTypeI32, api.ValueTypeI32, // contract_id + api.ValueTypeI32, api.ValueTypeI32, // method + api.ValueTypeI32, api.ValueTypeI32, // args_json + }, []api.ValueType{api.ValueTypeI32}). + Export("call_contract") + + // --- gas_tick() --- + // Called by instrumented loop headers; charges 1 gas unit per iteration. + // Panics (traps the WASM module) when the gas budget is exhausted. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, _ api.Module, _ []uint64) { + gc := gasFromContext(ctx) + if gc == nil { + return + } + if err := gc.charge(1); err != nil { + panic(err.Error()) // wazero catches this and surfaces it as a Call error + } + }), []api.ValueType{}, []api.ValueType{}). + Export("gas_tick") + + inst, err := b.Instantiate(ctx) + if err != nil { + log.Printf("[VM] registerHostModule: %v", err) + return nil, err + } + return inst, nil +} diff --git a/vm/instrument.go b/vm/instrument.go new file mode 100644 index 0000000..ac0710d --- /dev/null +++ b/vm/instrument.go @@ -0,0 +1,949 @@ +// Package vm — WASM bytecode instrumentation for instruction-level gas metering. +// +// Instrument rewrites a WASM binary so that every loop header calls the host +// function env.gas_tick(). This guarantees that any infinite loop—or any loop +// whose iteration count is proportional to attacker-controlled input—is bounded +// by the transaction gas limit. +// +// Algorithm +// +// 1. Parse sections. +// 2. Find or add the function type () → () in the type section. +// 3. Append env.gas_tick as the last function import (so existing import +// indices are unchanged). Record numOldFuncImports = M. +// 4. Rewrite the export section: any function export with index ≥ M → +1. +// 5. Rewrite the code section: +// • "call N" where N ≥ M → "call N+1" (defined-function shift) +// • "loop bt …" injects "call $gas_tick" immediately after "loop bt" +// 6. Reassemble. +// +// The call to gas_tick is also metered by the FunctionListener (100 gas/call), +// so each loop iteration costs at least 101 gas units total. +// +// If the binary already contains an env.gas_tick import the function returns +// the original bytes unchanged (idempotent). +package vm + +import ( + "errors" + "fmt" +) + +// ── section and opcode constants ───────────────────────────────────────────── + +const ( + wasmMagic = "\x00asm" + wasmVersion = "\x01\x00\x00\x00" + secType = 1 + secImport = 2 + secExport = 7 + secCode = 10 + importFunc = 0x00 + exportFunc = 0x00 + opBlock = 0x02 + opLoop = 0x03 + opIf = 0x04 + opElse = 0x05 + opEnd = 0x0B + opBr = 0x0C + opBrIf = 0x0D + opBrTable = 0x0E + opCall = 0x10 + opCallInd = 0x11 + opLocalGet = 0x20 + opLocalSet = 0x21 + opLocalTee = 0x22 + opGlobalGet = 0x23 + opGlobalSet = 0x24 + opTableGet = 0x25 + opTableSet = 0x26 + opI32Const = 0x41 + opI64Const = 0x42 + opF32Const = 0x43 + opF64Const = 0x44 + opMemSize = 0x3F + opMemGrow = 0x40 + opRefNull = 0xD0 + opRefIsNull = 0xD1 + opRefFunc = 0xD2 + opSelectT = 0x1C + opPrefixFC = 0xFC +) + +// ── LEB128 helpers ──────────────────────────────────────────────────────────── + +func readU32Leb(b []byte) (uint32, int) { + var x uint32 + var s uint + for i, by := range b { + if i == 5 { + return 0, -1 + } + x |= uint32(by&0x7F) << s + s += 7 + if by&0x80 == 0 { + return x, i + 1 + } + } + return 0, -1 +} + +func readI32Leb(b []byte) (int32, int) { + var x int32 + var s uint + for i, by := range b { + if i == 5 { + return 0, -1 + } + x |= int32(by&0x7F) << s + s += 7 + if by&0x80 == 0 { + if s < 32 && (by&0x40) != 0 { + x |= ^0 << s + } + return x, i + 1 + } + } + return 0, -1 +} + +func readI64Leb(b []byte) (int64, int) { + var x int64 + var s uint + for i, by := range b { + if i == 10 { + return 0, -1 + } + x |= int64(by&0x7F) << s + s += 7 + if by&0x80 == 0 { + if s < 64 && (by&0x40) != 0 { + x |= ^int64(0) << s + } + return x, i + 1 + } + } + return 0, -1 +} + +func appendU32Leb(out []byte, v uint32) []byte { + for { + b := byte(v & 0x7F) + v >>= 7 + if v != 0 { + b |= 0x80 + } + out = append(out, b) + if v == 0 { + break + } + } + return out +} + +// skipU32Leb returns the number of bytes consumed by one unsigned LEB128. +func skipU32Leb(b []byte) (int, error) { + _, n := readU32Leb(b) + if n <= 0 { + return 0, errors.New("bad unsigned LEB128") + } + return n, nil +} + +func skipI32Leb(b []byte) (int, error) { + _, n := readI32Leb(b) + if n <= 0 { + return 0, errors.New("bad signed LEB128 i32") + } + return n, nil +} + +func skipI64Leb(b []byte) (int, error) { + _, n := readI64Leb(b) + if n <= 0 { + return 0, errors.New("bad signed LEB128 i64") + } + return n, nil +} + +// ── WASM type for gas_tick: () → () ────────────────────────────────────────── +// Encoded as: 0x60 (functype) 0x00 (0 params) 0x00 (0 results) +var gasTickFuncType = []byte{0x60, 0x00, 0x00} + +// ── Top-level Instrument ────────────────────────────────────────────────────── + +// rawSection holds one parsed section. +type rawSection struct { + id byte + data []byte +} + +// Instrument returns a copy of wasm with env.gas_tick calls injected at every +// loop header. Returns wasm unchanged if already instrumented or if wasm has +// no code section. Returns an error only on malformed binaries. +func Instrument(wasm []byte) ([]byte, error) { + if len(wasm) < 8 || string(wasm[:4]) != wasmMagic || string(wasm[4:8]) != wasmVersion { + return nil, errors.New("not a valid WASM binary") + } + off := 8 + var sections []rawSection + for off < len(wasm) { + if off >= len(wasm) { + break + } + id := wasm[off] + off++ + size, n := readU32Leb(wasm[off:]) + if n <= 0 { + return nil, errors.New("bad section size LEB128") + } + off += n + end := off + int(size) + if end > len(wasm) { + return nil, fmt.Errorf("section %d size %d exceeds binary length", id, size) + } + sections = append(sections, rawSection{id: id, data: wasm[off:end]}) + off = end + } + + // Locate key sections. + typeIdx, importIdx, exportIdx, codeIdx := -1, -1, -1, -1 + for i, s := range sections { + switch s.id { + case secType: + typeIdx = i + case secImport: + importIdx = i + case secExport: + exportIdx = i + case secCode: + codeIdx = i + } + } + if codeIdx < 0 { + return wasm, nil // nothing to instrument + } + + // Idempotency: already instrumented? + if importIdx >= 0 && containsGasTick(sections[importIdx].data) { + return wasm, nil + } + + // ── Step 1: find or add gas_tick type ───────────────────────────────────── + var gasTickTypeIdx uint32 + if typeIdx >= 0 { + var err error + sections[typeIdx].data, gasTickTypeIdx, err = ensureGasTickType(sections[typeIdx].data) + if err != nil { + return nil, fmt.Errorf("type section: %w", err) + } + } else { + // Create a minimal type section containing only the gas_tick type. + ts := appendU32Leb(nil, 1) // count = 1 + ts = append(ts, gasTickFuncType...) + sections = insertBefore(sections, secImport, rawSection{id: secType, data: ts}) + // Recalculate section indices. + typeIdx, importIdx, exportIdx, codeIdx = findSections(sections) + gasTickTypeIdx = 0 + } + + // ── Step 2: add gas_tick import, count old func imports ─────────────────── + var numOldFuncImports uint32 + var gasFnIdx uint32 + if importIdx >= 0 { + var err error + sections[importIdx].data, numOldFuncImports, err = appendGasTickImport(sections[importIdx].data, gasTickTypeIdx) + if err != nil { + return nil, fmt.Errorf("import section: %w", err) + } + gasFnIdx = numOldFuncImports // gas_tick is the new last import + } else { + // Build import section with just gas_tick. + is, err := buildImportSection(gasTickTypeIdx) + if err != nil { + return nil, err + } + sections = insertBefore(sections, secExport, rawSection{id: secImport, data: is}) + typeIdx, importIdx, exportIdx, codeIdx = findSections(sections) + numOldFuncImports = 0 + gasFnIdx = 0 + } + + // ── Step 3: adjust export section ───────────────────────────────────────── + if exportIdx >= 0 { + var err error + sections[exportIdx].data, err = adjustExportFuncIndices(sections[exportIdx].data, numOldFuncImports) + if err != nil { + return nil, fmt.Errorf("export section: %w", err) + } + } + + // ── Step 4: rewrite code section ────────────────────────────────────────── + var err error + sections[codeIdx].data, err = rewriteCodeSection(sections[codeIdx].data, numOldFuncImports, gasFnIdx) + if err != nil { + return nil, fmt.Errorf("code section: %w", err) + } + + // ── Reassemble ──────────────────────────────────────────────────────────── + out := make([]byte, 0, len(wasm)+128) + out = append(out, []byte(wasmMagic+wasmVersion)...) + for _, s := range sections { + out = append(out, s.id) + out = appendU32Leb(out, uint32(len(s.data))) + out = append(out, s.data...) + } + return out, nil +} + +// ── type section helpers ────────────────────────────────────────────────────── + +// ensureGasTickType finds () → () in the type section or appends it. +// Returns the (possibly rewritten) section bytes and the type index. +func ensureGasTickType(data []byte) ([]byte, uint32, error) { + off := 0 + count, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad type count") + } + off += n + start := off + for i := uint32(0); i < count; i++ { + entryStart := off + if off >= len(data) || data[off] != 0x60 { + return nil, 0, fmt.Errorf("type %d: expected 0x60", i) + } + off++ + // params + pc, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, fmt.Errorf("type %d: bad param count", i) + } + off += n + paramStart := off + off += int(pc) // each valtype is 1 byte + // results + rc, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, fmt.Errorf("type %d: bad result count", i) + } + off += n + off += int(rc) + // Is this () → () ? + if pc == 0 && rc == 0 { + _ = paramStart + _ = entryStart + _ = start + return data, i, nil // already present + } + } + // Not found — append. + newData := appendU32Leb(nil, count+1) + newData = append(newData, data[start:]...) // all existing type entries + newData = append(newData, gasTickFuncType...) + return newData, count, nil +} + +// ── import section helpers ──────────────────────────────────────────────────── + +// containsGasTick returns true if env.gas_tick is already imported. +func containsGasTick(data []byte) bool { + off := 0 + count, n := readU32Leb(data[off:]) + if n <= 0 { + return false + } + off += n + for i := uint32(0); i < count; i++ { + // mod name + ml, n := readU32Leb(data[off:]) + if n <= 0 { + return false + } + off += n + mod := string(data[off : off+int(ml)]) + off += int(ml) + // field name + fl, n := readU32Leb(data[off:]) + if n <= 0 { + return false + } + off += n + field := string(data[off : off+int(fl)]) + off += int(fl) + // importdesc kind + if off >= len(data) { + return false + } + kind := data[off] + off++ + if mod == "env" && field == "gas_tick" { + return true + } + switch kind { + case 0x00: // function: type idx + _, n = readU32Leb(data[off:]) + if n <= 0 { + return false + } + off += n + case 0x01: // table: reftype + limits + off++ // reftype + lkind := data[off] + off++ + _, n = readU32Leb(data[off:]) + if n <= 0 { + return false + } + off += n + if lkind == 1 { + _, n = readU32Leb(data[off:]) + if n <= 0 { + return false + } + off += n + } + case 0x02: // memory: limits + lkind := data[off] + off++ + _, n = readU32Leb(data[off:]) + if n <= 0 { + return false + } + off += n + if lkind == 1 { + _, n = readU32Leb(data[off:]) + if n <= 0 { + return false + } + off += n + } + case 0x03: // global: valtype + mutability + off += 2 + } + } + return false +} + +// appendGasTickImport adds env.gas_tick as the last function import. +// Returns (newData, numOldFuncImports, error). +func appendGasTickImport(data []byte, typeIdx uint32) ([]byte, uint32, error) { + off := 0 + count, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad import count") + } + off += n + start := off + var numFuncImports uint32 + for i := uint32(0); i < count; i++ { + // mod name + ml, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad import mod len") + } + off += n + int(ml) + // field name + fl, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad import field len") + } + off += n + int(fl) + // importdesc + if off >= len(data) { + return nil, 0, errors.New("truncated importdesc") + } + kind := data[off] + off++ + switch kind { + case 0x00: // function + numFuncImports++ + _, n = readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad func import type idx") + } + off += n + case 0x01: // table + off++ // reftype + lkind := data[off] + off++ + _, n = readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad table import limit") + } + off += n + if lkind == 1 { + _, n = readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad table import max") + } + off += n + } + case 0x02: // memory + lkind := data[off] + off++ + _, n = readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad mem import limit") + } + off += n + if lkind == 1 { + _, n = readU32Leb(data[off:]) + if n <= 0 { + return nil, 0, errors.New("bad mem import max") + } + off += n + } + case 0x03: // global + off += 2 + } + } + + // Build new import section: count+1, existing entries, then gas_tick entry. + newData := appendU32Leb(nil, count+1) + newData = append(newData, data[start:]...) + // env.gas_tick entry + newData = appendU32Leb(newData, 3) // "env" length + newData = append(newData, "env"...) + newData = appendU32Leb(newData, 8) // "gas_tick" length + newData = append(newData, "gas_tick"...) + newData = append(newData, importFunc) // kind = function + newData = appendU32Leb(newData, typeIdx) + return newData, numFuncImports, nil +} + +// buildImportSection builds a section containing only env.gas_tick. +func buildImportSection(typeIdx uint32) ([]byte, error) { + var data []byte + data = appendU32Leb(data, 1) // count = 1 + data = appendU32Leb(data, 3) + data = append(data, "env"...) + data = appendU32Leb(data, 8) + data = append(data, "gas_tick"...) + data = append(data, importFunc) + data = appendU32Leb(data, typeIdx) + return data, nil +} + +// ── export section adjustment ───────────────────────────────────────────────── + +// adjustExportFuncIndices increments function export indices ≥ threshold by 1. +func adjustExportFuncIndices(data []byte, threshold uint32) ([]byte, error) { + off := 0 + count, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, errors.New("bad export count") + } + off += n + + out := appendU32Leb(nil, count) + for i := uint32(0); i < count; i++ { + // name + nl, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, fmt.Errorf("export %d: bad name len", i) + } + off += n + out = appendU32Leb(out, nl) + out = append(out, data[off:off+int(nl)]...) + off += int(nl) + // kind + if off >= len(data) { + return nil, fmt.Errorf("export %d: truncated kind", i) + } + kind := data[off] + off++ + out = append(out, kind) + // index + idx, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, fmt.Errorf("export %d: bad index", i) + } + off += n + if kind == exportFunc && idx >= threshold { + idx++ + } + out = appendU32Leb(out, idx) + } + return out, nil +} + +// ── code section rewriting ──────────────────────────────────────────────────── + +func rewriteCodeSection(data []byte, numOldImports, gasFnIdx uint32) ([]byte, error) { + off := 0 + count, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, errors.New("bad code section count") + } + off += n + + out := appendU32Leb(nil, count) + for i := uint32(0); i < count; i++ { + bodySize, n := readU32Leb(data[off:]) + if n <= 0 { + return nil, fmt.Errorf("code entry %d: bad body size", i) + } + off += n + body := data[off : off+int(bodySize)] + off += int(bodySize) + + newBody, err := rewriteFuncBody(body, numOldImports, gasFnIdx) + if err != nil { + return nil, fmt.Errorf("code entry %d: %w", i, err) + } + out = appendU32Leb(out, uint32(len(newBody))) + out = append(out, newBody...) + } + return out, nil +} + +func rewriteFuncBody(body []byte, numOldImports, gasFnIdx uint32) ([]byte, error) { + off := 0 + // local variable declarations + localGroupCount, n := readU32Leb(body[off:]) + if n <= 0 { + return nil, errors.New("bad local group count") + } + var out []byte + out = appendU32Leb(out, localGroupCount) + off += n + for i := uint32(0); i < localGroupCount; i++ { + cnt, n := readU32Leb(body[off:]) + if n <= 0 { + return nil, fmt.Errorf("local group %d: bad count", i) + } + off += n + if off >= len(body) { + return nil, fmt.Errorf("local group %d: missing valtype", i) + } + out = appendU32Leb(out, cnt) + out = append(out, body[off]) // valtype + off++ + } + // instruction stream + instrs, err := rewriteExpr(body[off:], numOldImports, gasFnIdx) + if err != nil { + return nil, err + } + out = append(out, instrs...) + return out, nil +} + +// rewriteExpr processes one expression (terminated by the matching end). +func rewriteExpr(code []byte, numOldImports, gasFnIdx uint32) ([]byte, error) { + off := 0 + var out []byte + depth := 1 // implicit outer scope; final end at depth==1 terminates expression + + for off < len(code) { + op := code[off] + + switch op { + case opBlock, opIf: + off++ + btLen, err := blocktypeLen(code[off:]) + if err != nil { + return nil, fmt.Errorf("block/if blocktype: %w", err) + } + out = append(out, op) + out = append(out, code[off:off+btLen]...) + off += btLen + depth++ + + case opLoop: + off++ + btLen, err := blocktypeLen(code[off:]) + if err != nil { + return nil, fmt.Errorf("loop blocktype: %w", err) + } + out = append(out, op) + out = append(out, code[off:off+btLen]...) + off += btLen + depth++ + // Inject gas_tick call at loop header. + out = append(out, opCall) + out = appendU32Leb(out, gasFnIdx) + + case opElse: + out = append(out, op) + off++ + + case opEnd: + out = append(out, op) + off++ + depth-- + if depth == 0 { + return out, nil + } + + case opCall: + off++ + idx, n := readU32Leb(code[off:]) + if n <= 0 { + return nil, errors.New("call: bad function index LEB128") + } + off += n + if idx >= numOldImports { + idx++ // shift defined-function index + } + out = append(out, opCall) + out = appendU32Leb(out, idx) + + case opCallInd: + off++ + typeIdx, n := readU32Leb(code[off:]) + if n <= 0 { + return nil, errors.New("call_indirect: bad type index") + } + off += n + tableIdx, n := readU32Leb(code[off:]) + if n <= 0 { + return nil, errors.New("call_indirect: bad table index") + } + off += n + out = append(out, opCallInd) + out = appendU32Leb(out, typeIdx) + out = appendU32Leb(out, tableIdx) + + case opRefFunc: + off++ + idx, n := readU32Leb(code[off:]) + if n <= 0 { + return nil, errors.New("ref.func: bad index") + } + off += n + if idx >= numOldImports { + idx++ + } + out = append(out, opRefFunc) + out = appendU32Leb(out, idx) + + default: + // Copy instruction verbatim; instrLen handles all remaining opcodes. + ilen, err := instrLen(code[off:]) + if err != nil { + return nil, fmt.Errorf("at offset %d: %w", off, err) + } + out = append(out, code[off:off+ilen]...) + off += ilen + } + } + return nil, errors.New("expression missing terminating end") +} + +// blocktypeLen returns the byte length of a blocktype immediate. +// Blocktypes are SLEB128-encoded: value types and void are single negative bytes; +// type-index blocktypes are non-negative LEB128 (multi-byte for indices ≥ 64). +func blocktypeLen(b []byte) (int, error) { + _, n := readI32Leb(b) + if n <= 0 { + return 0, errors.New("bad blocktype encoding") + } + return n, nil +} + +// instrLen returns the total byte length (opcode + immediates) of the instruction +// at code[0]. It handles all opcodes EXCEPT block/loop/if/else/end/call/ +// call_indirect/ref.func — those are handled directly in rewriteExpr. +func instrLen(code []byte) (int, error) { + if len(code) == 0 { + return 0, errors.New("empty instruction stream") + } + op := code[0] + switch { + // 1-byte, no immediates — covers all integer/float arithmetic, comparisons, + // conversion, drop, select, return, unreachable, nop, else, end, ref.is_null. + case op == 0x00 || op == 0x01 || op == 0x0F || op == 0x1A || op == 0x1B || + op == opRefIsNull || + (op >= 0x45 && op <= 0xC4): + return 1, nil + + // br, br_if, local.get/set/tee, global.get/set, table.get/set — one u32 LEB128 + case op == opBr || op == opBrIf || + op == opLocalGet || op == opLocalSet || op == opLocalTee || + op == opGlobalGet || op == opGlobalSet || + op == opTableGet || op == opTableSet: + n, err := skipU32Leb(code[1:]) + return 1 + n, err + + // br_table: u32 count N, then N+1 u32 values + case op == opBrTable: + off := 1 + cnt, n := readU32Leb(code[off:]) + if n <= 0 { + return 0, errors.New("br_table: bad count") + } + off += n + for i := uint32(0); i <= cnt; i++ { + n, err := skipU32Leb(code[off:]) + if err != nil { + return 0, fmt.Errorf("br_table target %d: %w", i, err) + } + off += n + } + return off, nil + + // memory load/store (0x28–0x3E): align u32 + offset u32 + case op >= 0x28 && op <= 0x3E: + off := 1 + n, err := skipU32Leb(code[off:]) + if err != nil { + return 0, fmt.Errorf("mem instr 0x%02X align: %w", op, err) + } + off += n + n, err = skipU32Leb(code[off:]) + if err != nil { + return 0, fmt.Errorf("mem instr 0x%02X offset: %w", op, err) + } + return 1 + (off - 1 + n), nil + + // memory.size (0x3F), memory.grow (0x40): reserved byte 0x00 + case op == opMemSize || op == opMemGrow: + return 2, nil + + // i32.const: signed LEB128 i32 + case op == opI32Const: + n, err := skipI32Leb(code[1:]) + return 1 + n, err + + // i64.const: signed LEB128 i64 + case op == opI64Const: + n, err := skipI64Leb(code[1:]) + return 1 + n, err + + // f32.const: 4 raw bytes + case op == opF32Const: + if len(code) < 5 { + return 0, errors.New("f32.const: truncated") + } + return 5, nil + + // f64.const: 8 raw bytes + case op == opF64Const: + if len(code) < 9 { + return 0, errors.New("f64.const: truncated") + } + return 9, nil + + // ref.null: 1 byte reftype + case op == opRefNull: + return 2, nil + + // select with types (0x1C): u32 count + that many valtypes (each 1 byte) + case op == opSelectT: + cnt, n := readU32Leb(code[1:]) + if n <= 0 { + return 0, errors.New("select t: bad count") + } + return 1 + n + int(cnt), nil + + // 0xFC prefix — saturating truncation, table/memory bulk ops + case op == opPrefixFC: + sub, n := readU32Leb(code[1:]) + if n <= 0 { + return 0, errors.New("0xFC: bad sub-opcode") + } + off := 1 + n + // sub-ops that have additional immediates + switch sub { + case 8: // memory.init: data_idx u32, mem idx u8 (0x00) + n2, err := skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + off++ // reserved memory index byte + case 9: // data.drop: data_idx u32 + n2, err := skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + case 10: // memory.copy: two reserved 0x00 bytes + off += 2 + case 11: // memory.fill: reserved 0x00 + off++ + case 12: // table.init: elem_idx u32, table_idx u32 + n2, err := skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + n2, err = skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + case 13: // elem.drop: elem_idx u32 + n2, err := skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + case 14: // table.copy: dst_idx u32, src_idx u32 + n2, err := skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + n2, err = skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + case 15, 16: // table.grow, table.size: table_idx u32 + n2, err := skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + case 17: // table.fill: table_idx u32 + n2, err := skipU32Leb(code[off:]) + if err != nil { + return 0, err + } + off += n2 + // 0–7: i32/i64 saturating truncation — no additional immediates + } + return off, nil + + default: + return 0, fmt.Errorf("unknown opcode 0x%02X", op) + } +} + +// ── section list helpers ────────────────────────────────────────────────────── + +func findSections(ss []rawSection) (typeIdx, importIdx, exportIdx, codeIdx int) { + typeIdx, importIdx, exportIdx, codeIdx = -1, -1, -1, -1 + for i, s := range ss { + switch s.id { + case secType: + typeIdx = i + case secImport: + importIdx = i + case secExport: + exportIdx = i + case secCode: + codeIdx = i + } + } + return +} + +// insertBefore inserts ns before the first section with the given id, +// or at the end if no such section exists. +func insertBefore(ss []rawSection, id byte, ns rawSection) []rawSection { + for i, s := range ss { + if s.id == id { + result := make([]rawSection, 0, len(ss)+1) + result = append(result, ss[:i]...) + result = append(result, ns) + result = append(result, ss[i:]...) + return result + } + } + return append(ss, ns) +} + diff --git a/vm/vm.go b/vm/vm.go new file mode 100644 index 0000000..607529f --- /dev/null +++ b/vm/vm.go @@ -0,0 +1,184 @@ +// Package vm provides a WASM-based smart contract execution engine. +// +// The engine uses wazero (pure-Go, no CGO) in interpreter mode to guarantee +// deterministic execution across all platforms — a requirement for consensus. +// +// Contract lifecycle: +// 1. DEPLOY_CONTRACT tx → chain calls Validate() to compile-check the WASM, +// then stores the bytecode in BadgerDB. +// 2. CALL_CONTRACT tx → chain calls Call() with the stored WASM bytes, +// the method name, JSON args, and a gas limit. +// +// Gas model: each WASM function call costs gasPerCall (100) units. +// Gas cost in µT = gasUsed × blockchain.GasPrice. +package vm + +import ( + "context" + "fmt" + "log" + "sync" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + + "go-blockchain/blockchain" +) + +// VM is a WASM execution engine. Create one per process; it is safe for +// concurrent use. Compiled modules are cached by contract ID. +type VM struct { + rt wazero.Runtime + mu sync.RWMutex + cache map[string]wazero.CompiledModule // contractID → compiled module +} + +// NewVM creates a VM with an interpreter-mode wazero runtime. +// Interpreter mode guarantees identical behaviour on all OS/arch combos, +// which is required for all nodes to reach the same state. +// +// WASI preview 1 is pre-instantiated to support contracts compiled with +// TinyGo's wasip1 target (tinygo build -target wasip1). Contracts that do +// not import from wasi_snapshot_preview1 are unaffected. +func NewVM(ctx context.Context) *VM { + cfg := wazero.NewRuntimeConfigInterpreter() + cfg = cfg.WithDebugInfoEnabled(false) + // Enable cooperative cancellation: fn.Call(ctx) will return when ctx is + // cancelled, even if the contract is in an infinite loop. Without this, + // a buggy or malicious contract that dodges gas metering (e.g. a tight + // loop of opcodes not hooked by the gas listener) would hang the + // AddBlock goroutine forever, freezing the entire chain. + cfg = cfg.WithCloseOnContextDone(true) + // Attach call-level gas metering. + ctx = experimental.WithFunctionListenerFactory(ctx, gasListenerFactory{}) + rt := wazero.NewRuntimeWithConfig(ctx, cfg) + // Instantiate WASI so TinyGo contracts can call proc_exit and basic I/O. + // The sandbox is fully isolated — no filesystem or network access. + wasi_snapshot_preview1.MustInstantiate(ctx, rt) + return &VM{ + rt: rt, + cache: make(map[string]wazero.CompiledModule), + } +} + +// Close releases the underlying wazero runtime. +func (v *VM) Close(ctx context.Context) error { + return v.rt.Close(ctx) +} + +// Validate compiles the WASM bytes without executing them. +// Returns an error if the bytes are not valid WASM or import unknown symbols. +// Implements blockchain.ContractVM. +func (v *VM) Validate(ctx context.Context, wasmBytes []byte) error { + _, err := v.rt.CompileModule(ctx, wasmBytes) + if err != nil { + return fmt.Errorf("invalid WASM module: %w", err) + } + return nil +} + +// Call executes method on the contract identified by contractID. +// wasmBytes is the compiled WASM (loaded from DB by the chain). +// Returns gas consumed. Returns ErrOutOfGas (wrapping ErrTxFailed) on exhaustion. +// Implements blockchain.ContractVM. +func (v *VM) Call( + ctx context.Context, + contractID string, + wasmBytes []byte, + method string, + argsJSON []byte, + gasLimit uint64, + env blockchain.VMHostEnv, +) (gasUsed uint64, err error) { + // Attach gas counter to context. + ctx, gc := withGasCounter(ctx, gasLimit) + ctx = experimental.WithFunctionListenerFactory(ctx, gasListenerFactory{}) + + // Compile (or retrieve from cache). + compiled, err := v.compiled(ctx, contractID, wasmBytes) + if err != nil { + return 0, fmt.Errorf("compile contract %s: %w", contractID, err) + } + + // Instantiate the "env" host module for this call, wiring in typed args. + hostInst, err := registerHostModule(ctx, v.rt, env, argsJSON) + if err != nil { + return 0, fmt.Errorf("register host module: %w", err) + } + defer hostInst.Close(ctx) + + // Instantiate the contract module. + modCfg := wazero.NewModuleConfig(). + WithName(""). // anonymous — allows multiple concurrent instances + WithStartFunctions() // do not auto-call _start + mod, err := v.rt.InstantiateModule(ctx, compiled, modCfg) + if err != nil { + return gc.Used(), fmt.Errorf("instantiate contract: %w", err) + } + defer mod.Close(ctx) + + // Look up the exported method. + fn := mod.ExportedFunction(method) + if fn == nil { + return gc.Used(), fmt.Errorf("%w: method %q not exported by contract %s", + blockchain.ErrTxFailed, method, contractID) + } + + // Call. WASM functions called from Go pass args via the stack; our contracts + // use no parameters — all input/output goes through host state functions. + _, callErr := fn.Call(ctx) + gasUsed = gc.Used() + + if callErr != nil { + log.Printf("[VM] contract %s.%s error: %v", contractID[:8], method, callErr) + if isOutOfGas(gc) { + return gasUsed, ErrOutOfGas + } + return gasUsed, fmt.Errorf("%w: %v", blockchain.ErrTxFailed, callErr) + } + if isOutOfGas(gc) { + return gasUsed, ErrOutOfGas + } + return gasUsed, nil +} + +// compiled returns a cached compiled module, compiling it first if not cached. +// Before compiling, the WASM bytes are instrumented with loop-header gas_tick +// calls so that infinite loops are bounded by the gas limit. +func (v *VM) compiled(ctx context.Context, contractID string, wasmBytes []byte) (wazero.CompiledModule, error) { + v.mu.RLock() + cm, ok := v.cache[contractID] + v.mu.RUnlock() + if ok { + return cm, nil + } + + v.mu.Lock() + defer v.mu.Unlock() + // Double-check after acquiring write lock. + if cm, ok = v.cache[contractID]; ok { + return cm, nil + } + // Instrument: inject gas_tick at loop headers. + // On instrumentation failure, fall back to the original bytes so that + // unusual WASM features do not prevent execution entirely. + instrumented, err := Instrument(wasmBytes) + if err != nil { + log.Printf("[VM] instrument contract %s: %v (using original bytes)", contractID[:min8(contractID)], err) + instrumented = wasmBytes + } + compiled, err := v.rt.CompileModule(ctx, instrumented) + if err != nil { + return nil, err + } + v.cache[contractID] = compiled + return compiled, nil +} + +func min8(s string) int { + if len(s) < 8 { + return len(s) + } + return 8 +} diff --git a/vm/vm_test.go b/vm/vm_test.go new file mode 100644 index 0000000..3137648 --- /dev/null +++ b/vm/vm_test.go @@ -0,0 +1,684 @@ +package vm + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "strings" + "testing" + + "go-blockchain/blockchain" +) + +// ── mock host env ───────────────────────────────────────────────────────────── + +type mockEnv struct { + state map[string][]byte + balances map[string]uint64 + caller string + blockHeight uint64 + logs []string +} + +func newMockEnv(caller string) *mockEnv { + return &mockEnv{ + state: make(map[string][]byte), + balances: make(map[string]uint64), + caller: caller, + } +} + +func (m *mockEnv) GetState(key []byte) ([]byte, error) { + v := m.state[string(key)] + return v, nil +} +func (m *mockEnv) SetState(key, value []byte) error { + m.state[string(key)] = append([]byte(nil), value...) + return nil +} +func (m *mockEnv) GetBalance(pub string) (uint64, error) { return m.balances[pub], nil } +func (m *mockEnv) Transfer(from, to string, amount uint64) error { + if m.balances[from] < amount { + return errors.New("insufficient balance") + } + m.balances[from] -= amount + m.balances[to] += amount + return nil +} +func (m *mockEnv) GetCaller() string { return m.caller } +func (m *mockEnv) GetBlockHeight() uint64 { return m.blockHeight } +func (m *mockEnv) GetContractTreasury() string { + return "0000000000000000000000000000000000000000000000000000000000000000" +} +func (m *mockEnv) Log(msg string) { m.logs = append(m.logs, msg) } +func (m *mockEnv) CallContract(contractID, method string, argsJSON []byte, gasLimit uint64) (uint64, error) { + return 0, fmt.Errorf("CallContract not supported in test mock") +} + +// counterWASM loads counter.wasm relative to the test file. +func counterWASM(t *testing.T) []byte { + t.Helper() + data, err := os.ReadFile("../contracts/counter/counter.wasm") + if err != nil { + t.Fatalf("load counter.wasm: %v", err) + } + return data +} + +// readU64State reads an 8-byte big-endian uint64 from env state. +func readU64State(env *mockEnv, key string) uint64 { + v := env.state[key] + if len(v) < 8 { + return 0 + } + return binary.BigEndian.Uint64(v[:8]) +} + +// ── unit tests ──────────────────────────────────────────────────────────────── + +func TestValidate_ValidWASM(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasmBytes := counterWASM(t) + if err := v.Validate(ctx, wasmBytes); err != nil { + t.Fatalf("Validate valid WASM: %v", err) + } +} + +func TestValidate_InvalidBytes(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + if err := v.Validate(ctx, []byte("not a wasm file")); err == nil { + t.Fatal("expected error for invalid WASM bytes") + } +} + +func TestValidate_EmptyBytes(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + if err := v.Validate(ctx, []byte{}); err == nil { + t.Fatal("expected error for empty bytes") + } +} + +func TestCall_UnknownMethod(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasmBytes := counterWASM(t) + env := newMockEnv("caller1") + _, err := v.Call(ctx, "test-id", wasmBytes, "nonexistent", nil, 100_000, env) + if err == nil { + t.Fatal("expected error for unknown method") + } + if !errors.Is(err, blockchain.ErrTxFailed) { + t.Fatalf("expected ErrTxFailed, got: %v", err) + } +} + +func TestCall_GasExhausted(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasmBytes := counterWASM(t) + env := newMockEnv("caller1") + // Gas limit of 1 unit is far too low for any real work. + _, err := v.Call(ctx, "test-id", wasmBytes, "increment", nil, 1, env) + if err == nil { + t.Fatal("expected ErrOutOfGas") + } + if !errors.Is(err, ErrOutOfGas) { + t.Fatalf("expected ErrOutOfGas, got: %v", err) + } + if !errors.Is(err, blockchain.ErrTxFailed) { + t.Fatalf("ErrOutOfGas must wrap ErrTxFailed, got: %v", err) + } +} + +// ── integration tests (counter contract) ───────────────────────────────────── + +func TestCounter_Increment(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasmBytes := counterWASM(t) + env := newMockEnv("alice") + + for i := 1; i <= 3; i++ { + gasUsed, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env) + if err != nil { + t.Fatalf("increment %d: %v", i, err) + } + if gasUsed == 0 { + t.Errorf("increment %d: expected gas > 0", i) + } + got := readU64State(env, "counter") + if got != uint64(i) { + t.Errorf("after increment %d: counter=%d, want %d", i, got, i) + } + } +} + +func TestCounter_Get(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasmBytes := counterWASM(t) + env := newMockEnv("alice") + + // Increment twice then call get. + for i := 0; i < 2; i++ { + if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env); err != nil { + t.Fatalf("increment: %v", err) + } + } + if _, err := v.Call(ctx, "ctr", wasmBytes, "get", nil, 1_000_000, env); err != nil { + t.Fatalf("get: %v", err) + } + // get logs "get called" + logged := false + for _, l := range env.logs { + if l == "get called" { + logged = true + } + } + if !logged { + t.Error("expected 'get called' in logs") + } +} + +func TestCounter_Reset_AuthorizedOwner(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasmBytes := counterWASM(t) + env := newMockEnv("alice") + + // Increment 5 times. + for i := 0; i < 5; i++ { + if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env); err != nil { + t.Fatalf("increment: %v", err) + } + } + if got := readU64State(env, "counter"); got != 5 { + t.Fatalf("before reset: counter=%d, want 5", got) + } + + // First reset — alice becomes owner. + if _, err := v.Call(ctx, "ctr", wasmBytes, "reset", nil, 1_000_000, env); err != nil { + t.Fatalf("reset (owner set): %v", err) + } + if got := readU64State(env, "counter"); got != 0 { + t.Fatalf("after reset: counter=%d, want 0", got) + } + + // Increment again then reset again (alice is owner). + for i := 0; i < 3; i++ { + if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env); err != nil { + t.Fatalf("increment: %v", err) + } + } + if _, err := v.Call(ctx, "ctr", wasmBytes, "reset", nil, 1_000_000, env); err != nil { + t.Fatalf("reset (owner confirmed): %v", err) + } + if got := readU64State(env, "counter"); got != 0 { + t.Fatalf("second reset: counter=%d, want 0", got) + } +} + +func TestCounter_Reset_UnauthorizedRejected(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasmBytes := counterWASM(t) + envAlice := newMockEnv("alice") + + // Alice increments and performs first reset (sets herself as owner). + if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, envAlice); err != nil { + t.Fatalf("increment: %v", err) + } + if _, err := v.Call(ctx, "ctr", wasmBytes, "reset", nil, 1_000_000, envAlice); err != nil { + t.Fatalf("reset (set owner): %v", err) + } + + // Bob tries to reset using the same state but with his caller ID. + // We simulate this by setting the owner in bob's env to alice's value. + envBob := newMockEnv("bob") + envBob.state = envAlice.state // shared state (bob sees alice as owner) + + // Bob increments so counter > 0. + if _, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, envBob); err != nil { + t.Fatalf("bob increment: %v", err) + } + counterBefore := readU64State(envBob, "counter") + + // Bob tries reset — should be rejected (logs "unauthorized"). + if _, err := v.Call(ctx, "ctr", wasmBytes, "reset", nil, 1_000_000, envBob); err != nil { + t.Fatalf("bob reset call error: %v", err) + } + // Counter should not be 0. + if got := readU64State(envBob, "counter"); got == 0 { + t.Errorf("bob reset succeeded (counter=%d), expected it to be rejected (was %d)", got, counterBefore) + } + // Should have logged "unauthorized". + logged := false + for _, l := range envBob.logs { + if l == "unauthorized" { + logged = true + } + } + if !logged { + t.Error("expected 'unauthorized' log when bob tries to reset") + } +} + +func TestCounter_GasReturned(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasmBytes := counterWASM(t) + env := newMockEnv("alice") + + gasUsed, err := v.Call(ctx, "ctr", wasmBytes, "increment", nil, 1_000_000, env) + if err != nil { + t.Fatalf("increment: %v", err) + } + if gasUsed == 0 || gasUsed >= 1_000_000 { + t.Errorf("unexpected gas: %d", gasUsed) + } + t.Logf("increment gasUsed=%d", gasUsed) +} + +func TestABI_Validate(t *testing.T) { + a, err := ParseABI(`{"methods":[{"name":"increment","args":[]},{"name":"get","args":[]},{"name":"reset","args":[]}]}`) + if err != nil { + t.Fatalf("ParseABI: %v", err) + } + if !a.HasMethod("increment") { + t.Error("expected HasMethod(increment)") + } + if a.HasMethod("nonexistent") { + t.Error("unexpected HasMethod(nonexistent)") + } + if err := a.Validate("increment", nil); err != nil { + t.Errorf("Validate increment: %v", err) + } + if err := a.Validate("unknown", nil); err == nil { + t.Error("expected error for unknown method") + } +} + +func TestABI_NamedArgs(t *testing.T) { + a, err := ParseABI(`{"methods":[ + {"name":"register","args":[{"name":"name","type":"string"}]}, + {"name":"transfer","args":[{"name":"name","type":"string"},{"name":"new_owner","type":"string"}]} + ]}`) + if err != nil { + t.Fatalf("ParseABI: %v", err) + } + if !a.HasMethod("register") { + t.Error("expected HasMethod(register)") + } + // register expects 1 arg + if err := a.Validate("register", []byte(`["alice"]`)); err != nil { + t.Errorf("Validate register 1 arg: %v", err) + } + if err := a.Validate("register", []byte(`["alice","extra"]`)); err == nil { + t.Error("expected error for too many args") + } + // transfer expects 2 args + if err := a.Validate("transfer", []byte(`["alice","bob_pubkey"]`)); err != nil { + t.Errorf("Validate transfer 2 args: %v", err) + } + // Inspect arg metadata + if a.Methods[0].Args[0].Name != "name" { + t.Errorf("arg name: want 'name', got %q", a.Methods[0].Args[0].Name) + } + if a.Methods[0].Args[0].Type != "string" { + t.Errorf("arg type: want 'string', got %q", a.Methods[0].Args[0].Type) + } +} + +// ── name registry contract tests ────────────────────────────────────────────── + +func nameRegistryWASM(t *testing.T) []byte { + t.Helper() + data, err := os.ReadFile("../contracts/name_registry/name_registry.wasm") + if err != nil { + t.Fatalf("load name_registry.wasm: %v", err) + } + return data +} + +func TestNameRegistry_Register(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasm := nameRegistryWASM(t) + env := newMockEnv("alice_pubkey_hex") + + _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env) + if err != nil { + t.Fatalf("register: %v", err) + } + // State key "alice" should now contain the caller pubkey bytes. + val := env.state["alice"] + if string(val) != "alice_pubkey_hex" { + t.Errorf("state[alice] = %q, want %q", val, "alice_pubkey_hex") + } + // Should have logged something containing "registered" + if len(env.logs) == 0 || !strings.Contains(env.logs[len(env.logs)-1], "registered") { + t.Errorf("expected last log 'registered', got %v", env.logs) + } +} + +func TestNameRegistry_NameTaken(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasm := nameRegistryWASM(t) + env := newMockEnv("alice_pubkey_hex") + + // First registration succeeds. + if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env); err != nil { + t.Fatalf("first register: %v", err) + } + // Second registration by a different caller should log "name taken". + env2 := newMockEnv("bob_pubkey_hex") + env2.state = env.state // shared state + logsBefore := len(env2.logs) + if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env2); err != nil { + t.Fatalf("second register: %v", err) + } + found := false + for _, l := range env2.logs[logsBefore:] { + if strings.Contains(l, "name taken") { + found = true + } + } + if !found { + t.Errorf("expected 'name taken' log, got %v", env2.logs) + } + // Owner should still be alice. + if string(env.state["alice"]) != "alice_pubkey_hex" { + t.Error("owner changed unexpectedly") + } +} + +func TestNameRegistry_Resolve(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasm := nameRegistryWASM(t) + env := newMockEnv("alice_pubkey_hex") + + if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env); err != nil { + t.Fatalf("register: %v", err) + } + env.logs = nil + if _, err := v.Call(ctx, "reg", wasm, "resolve", []byte(`["alice"]`), 1_000_000, env); err != nil { + t.Fatalf("resolve: %v", err) + } + // Should log the owner pubkey (verbose: "owner: alice_pubkey_hex"). + if len(env.logs) == 0 || !strings.Contains(env.logs[0], "alice_pubkey_hex") { + t.Errorf("resolve logged %v, want something containing alice_pubkey_hex", env.logs) + } + + // Resolve unknown name logs "not found". + env.logs = nil + if _, err := v.Call(ctx, "reg", wasm, "resolve", []byte(`["unknown"]`), 1_000_000, env); err != nil { + t.Fatalf("resolve unknown: %v", err) + } + if len(env.logs) == 0 || !strings.Contains(env.logs[0], "not found") { + t.Errorf("expected 'not found', got %v", env.logs) + } +} + +func TestNameRegistry_Transfer(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasm := nameRegistryWASM(t) + envAlice := newMockEnv("alice_pubkey_hex") + + if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, envAlice); err != nil { + t.Fatalf("register: %v", err) + } + // Alice transfers "alice" to bob. + if _, err := v.Call(ctx, "reg", wasm, "transfer", + []byte(`["alice","bob_pubkey_hex"]`), 1_000_000, envAlice); err != nil { + t.Fatalf("transfer: %v", err) + } + if string(envAlice.state["alice"]) != "bob_pubkey_hex" { + t.Errorf("state[alice] = %q, want bob_pubkey_hex", envAlice.state["alice"]) + } + lastLog := envAlice.logs[len(envAlice.logs)-1] + if !strings.Contains(lastLog, "transferred") { + t.Errorf("expected 'transferred' log, got %q", lastLog) + } +} + +func TestNameRegistry_Transfer_Unauthorized(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasm := nameRegistryWASM(t) + envAlice := newMockEnv("alice_pubkey_hex") + + if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, envAlice); err != nil { + t.Fatalf("register: %v", err) + } + + // Bob tries to transfer alice's name. + envBob := newMockEnv("bob_pubkey_hex") + envBob.state = envAlice.state + if _, err := v.Call(ctx, "reg", wasm, "transfer", + []byte(`["alice","bob_pubkey_hex"]`), 1_000_000, envBob); err != nil { + t.Fatalf("transfer call: %v", err) + } + // Owner should still be alice. + if string(envBob.state["alice"]) != "alice_pubkey_hex" { + t.Errorf("unauthorized transfer succeeded, state = %q", envBob.state["alice"]) + } + found := false + for _, l := range envBob.logs { + if strings.Contains(l, "unauthorized") { + found = true + } + } + if !found { + t.Errorf("expected 'unauthorized' log, got %v", envBob.logs) + } +} + +func TestNameRegistry_Release(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasm := nameRegistryWASM(t) + env := newMockEnv("alice_pubkey_hex") + + if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env); err != nil { + t.Fatalf("register: %v", err) + } + if _, err := v.Call(ctx, "reg", wasm, "release", []byte(`["alice"]`), 1_000_000, env); err != nil { + t.Fatalf("release: %v", err) + } + // State should now be empty (released). + val := env.state["alice"] + if len(val) > 0 { + t.Errorf("after release state[alice] = %q, want empty", val) + } + lastLog := env.logs[len(env.logs)-1] + if !strings.Contains(lastLog, "released") { + t.Errorf("expected 'released' log, got %q", lastLog) + } + + // After release, anyone can re-register. + env2 := newMockEnv("charlie_pubkey_hex") + env2.state = env.state + if _, err := v.Call(ctx, "reg", wasm, "register", []byte(`["alice"]`), 1_000_000, env2); err != nil { + t.Fatalf("re-register after release: %v", err) + } + if string(env2.state["alice"]) != "charlie_pubkey_hex" { + t.Errorf("re-register: state[alice] = %q, want charlie_pubkey_hex", env2.state["alice"]) + } +} + +// ── Phase 9: instruction-level gas metering ─────────────────────────────────── + +// infiniteLoopWASM is a hand-encoded WASM module that exports one function, +// "loop_forever", which contains an unconditional infinite loop. +// The Instrument function must inject gas_tick so the loop is terminated. +// +// WAT equivalent: +// +// (module +// (func (export "loop_forever") +// (loop $L (br $L)) +// ) +// ) +var infiniteLoopWASM = []byte{ + // magic + version + 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, + // type section: 1 type — () → () + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, + // function section: 1 function using type 0 + 0x03, 0x02, 0x01, 0x00, + // export section: export "loop_forever" as function 0 + 0x07, 0x10, 0x01, 0x0C, + 0x6C, 0x6F, 0x6F, 0x70, 0x5F, 0x66, 0x6F, 0x72, 0x65, 0x76, 0x65, 0x72, // "loop_forever" + 0x00, 0x00, + // code section: 1 entry — body: loop void; br 0; end; end + 0x0A, 0x09, 0x01, 0x07, 0x00, + 0x03, 0x40, // loop void + 0x0C, 0x00, // br 0 + 0x0B, // end (loop) + 0x0B, // end (function) +} + +// TestInstrument_InfiniteLoop verifies that Instrument succeeds and produces +// valid WASM for a module containing an infinite loop. +func TestInstrument_InfiniteLoop(t *testing.T) { + instrumented, err := Instrument(infiniteLoopWASM) + if err != nil { + t.Fatalf("Instrument: %v", err) + } + if len(instrumented) <= len(infiniteLoopWASM) { + t.Errorf("instrumented binary (%d B) not larger than original (%d B)", + len(instrumented), len(infiniteLoopWASM)) + } + // Must still be valid WASM. + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + if err := v.Validate(ctx, instrumented); err != nil { + t.Fatalf("Validate instrumented: %v", err) + } +} + +// TestInstrument_Idempotent verifies that instrumenting an already-instrumented +// binary is a no-op (returns identical bytes). +func TestInstrument_Idempotent(t *testing.T) { + once, err := Instrument(infiniteLoopWASM) + if err != nil { + t.Fatalf("first Instrument: %v", err) + } + twice, err := Instrument(once) + if err != nil { + t.Fatalf("second Instrument: %v", err) + } + if !bytes.Equal(once, twice) { + t.Error("second instrumentation changed the binary — not idempotent") + } +} + +// TestInfiniteLoop_TrappedByGas verifies that an infinite loop is terminated +// when the gas budget is exhausted. +func TestInfiniteLoop_TrappedByGas(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + env := newMockEnv("alice") + _, err := v.Call(ctx, "inf-loop", infiniteLoopWASM, "loop_forever", nil, 10_000, env) + if err == nil { + t.Fatal("expected error from infinite loop — should have been trapped by gas") + } + if !errors.Is(err, ErrOutOfGas) { + t.Fatalf("expected ErrOutOfGas, got: %v", err) + } + if !errors.Is(err, blockchain.ErrTxFailed) { + t.Fatalf("ErrOutOfGas must wrap ErrTxFailed, got: %v", err) + } +} + +// TestNameRegistry_GasIncludesLoopCost verifies that calling a contract method +// that exercises a loop (bytes_equal in name_registry) produces a non-zero +// gasUsed that is higher than the function-call minimum. +func TestNameRegistry_GasIncludesLoopCost(t *testing.T) { + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + + wasm := nameRegistryWASM(t) + env := newMockEnv("alice_pubkey_hex") + + gasUsed, err := v.Call(ctx, "reg-gas", wasm, "register", []byte(`["alice"]`), 1_000_000, env) + if err != nil { + t.Fatalf("register: %v", err) + } + // The name-registry register function calls bytes_equal in a loop. + // With loop-header metering, gas > just function-call overhead. + if gasUsed == 0 { + t.Error("gasUsed == 0, expected > 0") + } + t.Logf("name_registry.register gasUsed = %d", gasUsed) +} + +// TestInstrument_CounterWASM verifies that the counter contract (no loops) +// is instrumented without error and remains functionally identical. +func TestInstrument_CounterWASM(t *testing.T) { + original := counterWASM(t) + instrumented, err := Instrument(original) + if err != nil { + t.Fatalf("Instrument counter: %v", err) + } + // Counter WASM has no loops — instrumented size may equal original. + ctx := context.Background() + v := NewVM(ctx) + defer v.Close(ctx) + if err := v.Validate(ctx, instrumented); err != nil { + t.Fatalf("Validate instrumented counter: %v", err) + } + // Function still works after instrumentation. + env := newMockEnv("alice") + if _, err := v.Call(ctx, "ctr-instr", instrumented, "increment", nil, 1_000_000, env); err != nil { + t.Fatalf("increment on instrumented counter: %v", err) + } + if readU64State(env, "counter") != 1 { + t.Error("counter not incremented after instrumentation") + } +} diff --git a/wallet/wallet.go b/wallet/wallet.go new file mode 100644 index 0000000..35f2b2f --- /dev/null +++ b/wallet/wallet.go @@ -0,0 +1,221 @@ +// Package wallet manages Ed25519 keypairs with human-readable addresses, +// encrypted persistence, and two usage profiles: +// +// - NodeWallet — bound to a running validator node; earns block/relay rewards. +// - UserWallet — holds tokens for regular users; spends on transactions. +// +// Address format: "DC" + lowercase hex(sha256(pubkey)[0:12]) +// Example: DC3a7f2b1c9d0e5f6a7b8c9dab1f +// (2 + 24 = 26 characters, collision-resistant for this use case) +package wallet + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "golang.org/x/crypto/argon2" + + "go-blockchain/identity" +) + +// WalletType indicates how the wallet is used. +type WalletType string + +const ( + NodeWallet WalletType = "NODE" // validator node; earns rewards + UserWallet WalletType = "USER" // end-user; spends tokens +) + +// Wallet holds an Ed25519 identity plus metadata. +type Wallet struct { + Type WalletType + ID *identity.Identity + Label string // human-readable nickname + Address string // "DC..." address derived from pub key +} + +// New creates a fresh wallet of the given type and label. +func New(wtype WalletType, label string) (*Wallet, error) { + id, err := identity.Generate() + if err != nil { + return nil, err + } + return &Wallet{ + Type: wtype, + ID: id, + Label: label, + Address: PubKeyToAddress(id.PubKeyHex()), + }, nil +} + +// FromIdentity wraps an existing identity in a Wallet. +func FromIdentity(id *identity.Identity, wtype WalletType, label string) *Wallet { + return &Wallet{ + Type: wtype, + ID: id, + Label: label, + Address: PubKeyToAddress(id.PubKeyHex()), + } +} + +// PubKeyToAddress derives the "DC…" address from a hex-encoded Ed25519 public key. +func PubKeyToAddress(pubKeyHex string) string { + raw, err := hex.DecodeString(pubKeyHex) + if err != nil { + return "DCinvalid" + } + h := sha256.Sum256(raw) + return "DC" + hex.EncodeToString(h[:12]) +} + +// AddressToPubKeyPrefix returns the first 12 bytes of the address payload (for display). +func AddressToPubKeyPrefix(addr string) string { + if len(addr) < 2 { + return addr + } + return addr[2:] // strip "DC" prefix +} + +// --- persistence --- + +// walletFile is the JSON structure saved to disk. +// The private key is stored AES-256-GCM encrypted with Argon2id key derivation. +type walletFile struct { + Version int `json:"version"` + Type string `json:"type"` + Label string `json:"label"` + Address string `json:"address"` + PubKey string `json:"pub_key"` + + // Encryption envelope + KDFSalt string `json:"kdf_salt"` // hex-encoded 16-byte Argon2id salt + Nonce string `json:"nonce"` // hex-encoded 12-byte AES-GCM nonce + EncPriv string `json:"enc_priv"` // hex-encoded AES-256-GCM ciphertext of priv key +} + +// Save encrypts the wallet and writes it to path. +// passphrase may be "" (unencrypted, not recommended for production). +func (w *Wallet) Save(path, passphrase string) error { + privKeyBytes := []byte(hex.EncodeToString(w.ID.PrivKey)) + + salt := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return fmt.Errorf("generate salt: %w", err) + } + nonce := make([]byte, 12) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return fmt.Errorf("generate nonce: %w", err) + } + + key := deriveKey(passphrase, salt) + block, err := aes.NewCipher(key) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + ct := gcm.Seal(nil, nonce, privKeyBytes, nil) + + wf := walletFile{ + Version: 1, + Type: string(w.Type), + Label: w.Label, + Address: w.Address, + PubKey: w.ID.PubKeyHex(), + KDFSalt: hex.EncodeToString(salt), + Nonce: hex.EncodeToString(nonce), + EncPriv: hex.EncodeToString(ct), + } + data, err := json.MarshalIndent(wf, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +// Load decrypts a wallet file. +func Load(path, passphrase string) (*Wallet, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read wallet: %w", err) + } + var wf walletFile + if err := json.Unmarshal(data, &wf); err != nil { + return nil, fmt.Errorf("parse wallet: %w", err) + } + + salt, err := hex.DecodeString(wf.KDFSalt) + if err != nil { + return nil, fmt.Errorf("decode salt: %w", err) + } + nonce, err := hex.DecodeString(wf.Nonce) + if err != nil { + return nil, fmt.Errorf("decode nonce: %w", err) + } + ct, err := hex.DecodeString(wf.EncPriv) + if err != nil { + return nil, fmt.Errorf("decode ciphertext: %w", err) + } + + key := deriveKey(passphrase, salt) + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + privKeyHex, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return nil, errors.New("decryption failed: wrong passphrase?") + } + + id, err := identity.FromHex(wf.PubKey, string(privKeyHex)) + if err != nil { + return nil, fmt.Errorf("load identity: %w", err) + } + return &Wallet{ + Type: WalletType(wf.Type), + ID: id, + Label: wf.Label, + Address: wf.Address, + }, nil +} + +// deriveKey derives a 32-byte AES key from passphrase + salt using Argon2id. +func deriveKey(passphrase string, salt []byte) []byte { + // Argon2id parameters: time=1, memory=64MB, threads=4 + return argon2.IDKey([]byte(passphrase), salt, 1, 64*1024, 4, 32) +} + +// --- display helpers --- + +// Info returns a map suitable for JSON marshalling. +func (w *Wallet) Info() map[string]interface{} { + return map[string]interface{}{ + "type": string(w.Type), + "label": w.Label, + "address": w.Address, + "pub_key": w.ID.PubKeyHex(), + } +} + +// Short returns "label (DCxxxx…xxxx)". +func (w *Wallet) Short() string { + addr := w.Address + if len(addr) > 10 { + addr = addr[:6] + "…" + addr[len(addr)-4:] + } + return fmt.Sprintf("%s (%s)", w.Label, addr) +}