From c673b10a941fbbae1103a9dc4a1efe6056b73bc1 Mon Sep 17 00:00:00 2001 From: Grant Date: Wed, 17 Jun 2026 09:32:07 -0500 Subject: [PATCH] Add Stage 2 onboarding harness (buyer pays on regtest) Disposable rig that runs the onboarding-tester agent docs-only against the buyer-pays journey: a sandbox daemon wired to a Dockerized BTCPay regtest stack, a scoped key with payment_providers:write, and a regtest buyer-pay helper. Includes the de-risk probe + findings and an end-to-end gate check (validate-gate.sh, 10/10). The doc-onboarding loop converged completed-clean; see stage2/STAGE2-RESULT.md. Scratch (.live-env, probe-out/) is gitignored. --- onboarding-harness/README.md | 24 +++- onboarding-harness/stage2/STAGE2-RESULT.md | 73 +++++++++++ .../stage2/btcpay-regtest/.gitignore | 2 + .../stage2/btcpay-regtest/FINDINGS.md | 66 ++++++++++ .../stage2/btcpay-regtest/docker-compose.yml | 87 ++++++++++++ .../stage2/btcpay-regtest/probe.sh | 92 +++++++++++++ onboarding-harness/stage2/buyer-pay.sh | 30 +++++ onboarding-harness/stage2/run-stage2.sh | 124 ++++++++++++++++++ onboarding-harness/stage2/validate-gate.sh | 89 +++++++++++++ 9 files changed, 580 insertions(+), 7 deletions(-) create mode 100644 onboarding-harness/stage2/STAGE2-RESULT.md create mode 100644 onboarding-harness/stage2/btcpay-regtest/.gitignore create mode 100644 onboarding-harness/stage2/btcpay-regtest/FINDINGS.md create mode 100644 onboarding-harness/stage2/btcpay-regtest/docker-compose.yml create mode 100755 onboarding-harness/stage2/btcpay-regtest/probe.sh create mode 100755 onboarding-harness/stage2/buyer-pay.sh create mode 100755 onboarding-harness/stage2/run-stage2.sh create mode 100755 onboarding-harness/stage2/validate-gate.sh diff --git a/onboarding-harness/README.md b/onboarding-harness/README.md index cadce96..ee97329 100644 --- a/onboarding-harness/README.md +++ b/onboarding-harness/README.md @@ -43,14 +43,24 @@ Individual stages (`boot-fixture.sh`, `provision.sh`, `serve-docs.sh`, marketing copy), tear down, and re-run on a fresh fixture. 3. Repeat until `completed-clean`. -## Stage 2 (gated, not built yet) +## Stage 2 (buyer pays on regtest) — built, `completed-clean` -The buyer-pays-on-regtest path needs Keysat to ship `payment_providers:write` + -the sandbox-mode daemon flag + the network gate (slices 3–5, in progress). It -adds a Dockerized BTCPay regtest stack and grants the agent -`merchant-onboard` + `payment_providers:write` so it can connect BTCPay -(regtest) and drive a test buyer payment end to end. Connecting a *mainnet* -wallet stays operator-only by design — that boundary is a feature, not a gap. +Lives in `stage2/`. Boots a **sandbox** daemon (`KEYSAT_SANDBOX_MODE=1`) wired to +a Dockerized BTCPay **regtest** stack and grants the agent `merchant-onboard` + +`payment_providers:write` so it connects BTCPay (regtest) and drives a test buyer +payment end to end. Connecting a *mainnet* wallet stays operator-only by design — +that boundary is a feature, not a gap. + +```sh +(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time +./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key +# feed runs//AGENT_BRIEF.md to the onboarding-tester agent +``` + +- `stage2/btcpay-regtest/` — the BTCPay regtest compose + de-risk probe (`FINDINGS.md`). +- `stage2/validate-gate.sh` — end-to-end gate check (deny mainnet/undetermined, allow regtest). +- `stage2/buyer-pay.sh` — the test buyer's wallet (pay invoice on regtest + mine). +- `stage2/STAGE2-RESULT.md` — convergence + the publishable walkthrough. ## Requirements diff --git a/onboarding-harness/stage2/STAGE2-RESULT.md b/onboarding-harness/stage2/STAGE2-RESULT.md new file mode 100644 index 0000000..7354544 --- /dev/null +++ b/onboarding-harness/stage2/STAGE2-RESULT.md @@ -0,0 +1,73 @@ +# Stage 2 result — agent connects BTCPay (regtest) + buyer pays (payments) + +**Verdict: `completed-clean` on run 3 (0 findings).** A fresh adopter, using only the +published docs and a **scoped** key (`merchant-onboard` + `payment_providers:write`, no +master key), can connect a regtest BTCPay over the API with **no browser step**, stand up +a paid product, produce a buyer checkout, and have a **real (regtest) on-chain payment +settle into a signed license** that validates offline. + +This is the buyer-pays half of the onboarding harness (Stage 1 = no-payments SDK +integration). It is gated on the **agent-payment-connect** daemon feature (slices 3-4): +the scoped BTCPay connect is allowed only on a **sandbox** daemon for a **non-mainnet** +network. See `plans/agent-payment-connect-scope.md` and `stage2/FINDINGS.md`. + +## Method + +`stage2/run-stage2.sh` boots a disposable Keysat daemon in **sandbox mode** +(`KEYSAT_SANDBOX_MODE=1`) wired to the regtest BTCPay stack (`stage2/btcpay-regtest/`), +mints a scoped key carrying `payment_providers:write`, serves `keysat-docs/` as the +corpus, and materializes a sandbox app. The daemon binds `0.0.0.0` and registers its +settle webhook via `host.docker.internal` so the BTCPay container can reach it. The +global `onboarding-tester` agent then drives the journey **docs-only**. The test buyer's +wallet is `stage2/buyer-pay.sh` (pays the invoice on regtest + mines a confirmation). + +## Convergence + +| Run | Verdict | Findings | +|-----|---------|----------| +| 1 | blocked-at-step-1 (docs) | 2 blockers (agent.html#not-exposed said provider-connect is master-only; the connect/status/callback endpoints absent from OpenAPI) + 2 stumbles (headless callback pattern undocumented; `payment_providers:write` scope undocumented) + 1 nit. | +| 2 | **completed-clean** | 1 doc nit (install.html BTCPay permission list wrong) + 1 harness-script bug (`buyer-pay.sh` missing `-rpcwallet`). | +| 3 | **completed-clean (0)** | none. Walkthrough harvested below. | + +The capability worked end to end from run 1 (the agent connected BTCPay headlessly and got +a license); the blockers were purely that the docs *said it was impossible* and didn't +document the path. + +## Doc fixes shipped this loop + +**`keysat-docs/` (deploys independently):** +- `agent.html`: corrected the `#auth` master-only statement; added an **A-la-carte extra + scopes** subsection (`payment_providers:write`); narrowed `#not-exposed` to the accurate + gate (scoped connect allowed only sandbox + non-mainnet; disconnect + production/mainnet + stay master-only); added the **Connect BTCPay programmatically (sandbox)** workflow + (`#connect-btcpay`) with the 3-step API flow. +- `install.html`: corrected the BTCPay permission list to the five the daemon actually + requests; added an "automating setup?" pointer to the agent path. + +**`licensing-service/src/api/openapi.rs` (served spec; ships next daemon release):** +- Added `/v1/admin/btcpay/connect`, `/v1/btcpay/authorize/callback`, + `/v1/admin/btcpay/status`, `/v1/admin/btcpay/disconnect`; added the `scopes` field to + scoped-key creation; noted the read-only `sandbox` flag on `/v1/admin/tier`. + +## Reproduce + +```sh +(cd stage2/btcpay-regtest && docker compose -p keysat-btcpay up -d) # one-time +./stage2/run-stage2.sh # boots sandbox daemon + regtest wiring + scoped key +# feed runs//AGENT_BRIEF.md to the onboarding-tester agent +./teardown.sh runs/ # stops daemon + docs server +``` + +## Publishable walkthrough (harvested, run 3) + +All it took, on a sandbox Keysat with a scoped `payment_providers:write` key and a regtest +BTCPay store key (no master key, no browser): + +1. **Connect BTCPay** — `POST /v1/admin/btcpay/connect` -> `state`; then + `GET /v1/btcpay/authorize/callback?state=&apiKey=`; confirm with + `GET /v1/admin/btcpay/status`. +2. **Define a paid product** — `POST /v1/admin/products` + `POST /v1/admin/policies`. +3. **Create a checkout** — `POST /v1/purchase` -> `checkout_url` + `amount_sats`. +4. **Buyer pays** (regtest on-chain), daemon settles via webhook, `GET /v1/purchase/` + returns `status: settled` + a signed `license_key`. +5. **Validate** — `POST /v1/validate` -> `ok: true` with the tier's entitlements. diff --git a/onboarding-harness/stage2/btcpay-regtest/.gitignore b/onboarding-harness/stage2/btcpay-regtest/.gitignore new file mode 100644 index 0000000..07fecb2 --- /dev/null +++ b/onboarding-harness/stage2/btcpay-regtest/.gitignore @@ -0,0 +1,2 @@ +probe-out/ +.live-env diff --git a/onboarding-harness/stage2/btcpay-regtest/FINDINGS.md b/onboarding-harness/stage2/btcpay-regtest/FINDINGS.md new file mode 100644 index 0000000..fc46e83 --- /dev/null +++ b/onboarding-harness/stage2/btcpay-regtest/FINDINGS.md @@ -0,0 +1,66 @@ +# De-risk result — BTCPay regtest network detection (agent-payment-connect slice 3) + +**Verdict: the spec's primary network-detection assumption (§6.1) is VALIDATED against +a live regtest BTCPay 2.x. No blocker; slice 3 needs no extra OAuth permission.** + +Rig: `docker-compose.yml` in this dir — bitcoind(regtest) + NBXplorer + postgres + +btcpayserver `2.0.6`. Validated 2026-06-16. Probe: `probe.sh`; raw payloads in +`probe-out/`. Bring up `docker compose -p keysat-btcpay up -d`; tear down +`docker compose -p keysat-btcpay down -v`. + +## What the gate will actually see + +1. **Payment-method id is `BTC-CHAIN`** on BTCPay 2.x. Posting to the legacy `.../BTC/...` + path is normalized to `BTC-CHAIN`. **Do not hardcode** — BTCPay 1.x used `BTC`. Slice 3 + should read `paymentMethodId` from the list and pick the on-chain BTC method + (id ∈ {`BTC-CHAIN`,`BTC`}, not Lightning). + +2. **Primary signal — receive address HRP (spec §6.1 primary), CONFIRMED:** + `GET /api/v1/stores/{id}/payment-methods/BTC-CHAIN/wallet/address` + → `{"address":"bcrt1qwsh9ua5qeutshvrhz474uduwqlw8gfukfpc8vt","keyPath":"0/0","paymentLink":...}` + `bcrt1…` HRP ⇒ **regtest** ⇒ non-mainnet ⇒ scoped connect allowed (on a sandbox daemon). + Classification table (validated regtest arm; others by HRP spec): + `bc1`/base58 `1`,`3` → mainnet (deny scoped) · `tb1` → testnet/signet · `bcrt1` → regtest · + base58 `m`,`n`,`2` → test/regtest. + +3. **Secondary signal — derivation, CONFIRMED but field name differs from the spec.** + The spec says `derivationScheme`; on BTCPay 2.x Greenfield it is + **`config.accountDerivation`** (and `config.signingKey`, `config.accountKeySettings[].accountKey`), + value `tpubDC…` for regtest/testnet (mainnet → `xpub/ypub/zpub`). The BIP-84 account path + is `84'/1'/0'` — coin-type `1'` is itself a testnet/regtest marker. **Requires + `?includeConfig=true`** — see permission note below. + +## Permission — the daemon already has enough + +- The daemon's BTCPay OAuth (`REQUESTED_PERMISSIONS`, `btcpay_authorize.rs:45`) already + requests **`btcpay.store.canmodifystoresettings`** (for webhook registration). +- Empirically, with a token holding only `canmodifystoresettings`: + `wallet/address` → **HTTP 200**, and `payment-methods?includeConfig=true` → config **visible**. +- `wallet/address` specifically needs `canmodifystoresettings` (`canviewstoresettings` → + **403**). The `config`/derivation path needs only `canviewstoresettings`. +- ⇒ **Slice 3 can use EITHER signal with the key it already obtains at connect. No new + OAuth scope.** Recommend the **address-HRP path** (spec's primary; one call; unambiguous). + +## Fail-closed cases (all confirmed → treat as mainnet → master-only) + +- No on-chain wallet configured → `GET payment-methods` returns `[]` (no BTC-CHAIN method). +- `wallet/address` on a store with no wallet → **HTTP 503** `"BTC-CHAIN services are not + currently available"`. (Same 503 also appears transiently while BTCPay is not yet + `synchronized:true` — at operator connect time it will be synced, but treat any non-2xx / + missing address / unrecognized HRP as "cannot determine" ⇒ deny scoped, require master.) + +## Implication for the daemon client (slice 3) + +The existing `btcpay/client.rs::list_payment_methods` calls `GET .../payment-methods` +**without** `includeConfig`, so today it sees `config:null` (confirmed). To detect network, +add a small client fn that GETs `.../payment-methods/{pmid}/wallet/address` and classifies +the HRP (preferred), or pass `?includeConfig=true` and read `config.accountDerivation`. +Resolve target network **before persisting** the provider (spec §7). + +## Rig gotcha (for whoever rebuilds this) + +NBXplorer defaults to cookie auth; with separate datadir volumes BTCPay can't read the +cookie → `401` → BTCPay never reaches `synchronized:true` → on-chain `BTC-CHAIN` service +stays unavailable (`503`). Fix used here: `NBXPLORER_NOAUTH=1` (fine for a throwaway +regtest box). A production-faithful harness would instead share NBXplorer's datadir volume +into BTCPay so the cookie is shared. diff --git a/onboarding-harness/stage2/btcpay-regtest/docker-compose.yml b/onboarding-harness/stage2/btcpay-regtest/docker-compose.yml new file mode 100644 index 0000000..f791e1c --- /dev/null +++ b/onboarding-harness/stage2/btcpay-regtest/docker-compose.yml @@ -0,0 +1,87 @@ +# Throwaway BTCPay Server regtest stack — de-risk rig for agent-payment-connect +# network detection (spec §6.1). NOT a production deployment, NOT yet wired into +# the Stage 2 harness. Bring up: docker compose -p keysat-btcpay up -d +# Tear down (incl. volumes): docker compose -p keysat-btcpay down -v +# +# Ports published to the host: +# BTCPay UI/Greenfield API → http://127.0.0.1:49392 +# bitcoind regtest RPC → 127.0.0.1:43782 (user/pass keysat/keysat) +services: + bitcoind: + image: btcpayserver/bitcoin:28.1 + environment: + BITCOIN_NETWORK: regtest + BITCOIN_EXTRA_ARGS: | + rpcuser=keysat + rpcpassword=keysat + rpcbind=0.0.0.0:43782 + rpcallowip=0.0.0.0/0 + port=39388 + whitelist=0.0.0.0/0 + zmqpubrawblock=tcp://0.0.0.0:28332 + zmqpubrawtx=tcp://0.0.0.0:28333 + fallbackfee=0.0002 + txindex=1 + expose: + - "43782" + - "39388" + - "28332" + - "28333" + ports: + - "127.0.0.1:43782:43782" + volumes: + - bitcoin_datadir:/data + + postgres: + image: postgres:13.13 + environment: + POSTGRES_HOST_AUTH_METHOD: trust + volumes: + - postgres_datadir:/var/lib/postgresql/data + + nbxplorer: + image: nicolasdorier/nbxplorer:2.5.22 + restart: unless-stopped + environment: + NBXPLORER_NETWORK: regtest + NBXPLORER_NOAUTH: "1" + NBXPLORER_BIND: 0.0.0.0:32838 + NBXPLORER_TRIMEVENTS: "10000" + NBXPLORER_SIGNALFILESDIR: /datadir + NBXPLORER_CHAINS: "btc" + NBXPLORER_BTCRPCURL: http://bitcoind:43782/ + NBXPLORER_BTCRPCUSER: keysat + NBXPLORER_BTCRPCPASSWORD: keysat + NBXPLORER_BTCNODEENDPOINT: bitcoind:39388 + NBXPLORER_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=nbxplorer;MaxPoolSize=20;Database=nbxplorer + depends_on: + - bitcoind + - postgres + volumes: + - nbxplorer_datadir:/datadir + + btcpayserver: + image: btcpayserver/btcpayserver:2.0.6 + restart: unless-stopped + environment: + BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Application Name=btcpayserver;MaxPoolSize=20;Database=btcpayserver + BTCPAY_NETWORK: regtest + BTCPAY_BIND: 0.0.0.0:49392 + BTCPAY_ROOTPATH: / + BTCPAY_PROTOCOL: http + BTCPAY_CHAINS: "btc" + BTCPAY_BTCEXPLORERURL: http://nbxplorer:32838/ + BTCPAY_DEBUGLOG: btcpay.log + ports: + - "127.0.0.1:49392:49392" + depends_on: + - nbxplorer + - postgres + volumes: + - btcpay_datadir:/datadir + +volumes: + bitcoin_datadir: + postgres_datadir: + nbxplorer_datadir: + btcpay_datadir: diff --git a/onboarding-harness/stage2/btcpay-regtest/probe.sh b/onboarding-harness/stage2/btcpay-regtest/probe.sh new file mode 100755 index 0000000..e98d4bd --- /dev/null +++ b/onboarding-harness/stage2/btcpay-regtest/probe.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# De-risk probe for agent-payment-connect network detection (spec §6.1). +# Stands up a store + on-chain regtest wallet on the local BTCPay regtest stack, +# then dumps the exact Greenfield responses the slice-3 gate would consult: +# - GET /api/v1/stores/{id}/payment-methods (paymentMethodId form? derivationScheme exposed?) +# - GET /api/v1/stores/{id}/payment-methods/{pmid}/wallet/address (bcrt1… prefix?) +# Read-only against Keysat; only mutates the throwaway BTCPay instance. +set -uo pipefail + +BASE="${BTCPAY_BASE:-http://127.0.0.1:49392}" +ADMIN_EMAIL="admin@keysat.local" +ADMIN_PW="keysatregtest1!" +OUT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/probe-out" +mkdir -p "$OUT_DIR" + +hr(){ printf '\n\033[1;36m=== %s ===\033[0m\n' "$*"; } +jqp(){ jq . 2>/dev/null || cat; } + +# --- 0. wait for BTCPay -------------------------------------------------------- +hr "0. waiting for BTCPay health at $BASE" +for i in $(seq 1 120); do + if curl -fsS "$BASE/api/v1/health" >/dev/null 2>&1; then break; fi + sleep 2 + [[ $i == 120 ]] && { echo "BTCPay never became healthy"; exit 1; } +done +curl -fsS "$BASE/api/v1/health" | jqp + +# --- 1. create first admin (unauthenticated, only works on a fresh instance) --- +hr "1. create first admin (idempotent: 'already exists' is fine)" +curl -sS -X POST "$BASE/api/v1/users" \ + -H 'Content-Type: application/json' \ + -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PW\",\"isAdministrator\":true}" | jqp + +# Basic-auth header for subsequent Greenfield calls. +AUTH=(-u "$ADMIN_EMAIL:$ADMIN_PW") + +# --- 2. create a store --------------------------------------------------------- +hr "2. create store" +STORE_JSON="$(curl -sS "${AUTH[@]}" -X POST "$BASE/api/v1/stores" \ + -H 'Content-Type: application/json' -d '{"name":"Keysat Regtest Co"}')" +echo "$STORE_JSON" | jqp +STORE_ID="$(echo "$STORE_JSON" | jq -r '.id')" +echo "STORE_ID=$STORE_ID" +[[ -z "$STORE_ID" || "$STORE_ID" == null ]] && { echo "no store id"; exit 1; } + +# --- 3. generate an on-chain wallet; try BTC-CHAIN then BTC -------------------- +gen_body='{"savePrivateKeys":false,"importKeysToRPC":false,"wordList":"English","wordCount":12,"scriptPubKeyType":"Segwit"}' +PMID="" +for cand in BTC-CHAIN BTC; do + hr "3. generate wallet on pmid=$cand" + code="$(curl -sS -o "$OUT_DIR/gen-$cand.json" -w '%{http_code}' "${AUTH[@]}" \ + -X POST "$BASE/api/v1/stores/$STORE_ID/payment-methods/$cand/wallet/generate" \ + -H 'Content-Type: application/json' -d "$gen_body")" + echo "HTTP $code"; cat "$OUT_DIR/gen-$cand.json" | jqp + if [[ "$code" == 2* ]]; then PMID="$cand"; break; fi +done +[[ -z "$PMID" ]] && echo "!! wallet generate failed for both pmid forms (see above)" + +# --- 4. mine some regtest blocks so the wallet has a usable address ------------ +hr "4. mine regtest blocks" +ADDR_FOR_MINE="$(docker exec keysat-btcpay-bitcoind-1 bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 getnewaddress 2>/dev/null || true)" +echo "miner address: ${ADDR_FOR_MINE:-}" +if [[ -n "$ADDR_FOR_MINE" ]]; then + docker exec keysat-btcpay-bitcoind-1 bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 generatetoaddress 101 "$ADDR_FOR_MINE" >/dev/null 2>&1 \ + && echo "mined 101 blocks" || echo "mine failed (non-fatal for detection probe)" +fi + +# --- 5. THE PAYLOADS the slice-3 gate consults -------------------------------- +hr "5a. GET payment-methods (does it expose derivationScheme? what pmid?)" +curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_ID/payment-methods?includeConfig=true" \ + | tee "$OUT_DIR/payment-methods.json" | jqp + +hr "5b. GET wallet/address (THE network artifact — expect bcrt1…)" +ADDR_JSON="$(curl -sS "${AUTH[@]}" "$BASE/api/v1/stores/$STORE_ID/payment-methods/${PMID:-BTC-CHAIN}/wallet/address")" +echo "$ADDR_JSON" | tee "$OUT_DIR/wallet-address.json" | jqp +ADDR="$(echo "$ADDR_JSON" | jq -r '.address // empty')" + +# --- 6. classify -------------------------------------------------------------- +hr "6. network classification" +echo "pmid used : ${PMID:-BTC-CHAIN}" +echo "receive address: ${ADDR:-}" +case "$ADDR" in + bcrt1*) echo "=> prefix bcrt1 => REGTEST ✅ (non-mainnet → scoped connect allowed)";; + tb1*) echo "=> prefix tb1 => TESTNET/SIGNET (non-mainnet)";; + bc1*) echo "=> prefix bc1 => MAINNET ❌";; + [mn2]*) echo "=> legacy base58 m/n/2 => TEST/REGTEST (non-mainnet)";; + [13]*) echo "=> legacy base58 1/3 => MAINNET ❌";; + "") echo "=> NO ADDRESS (Lightning-only / unconfigured) => FAIL-CLOSED → mainnet → master-only";; + *) echo "=> UNRECOGNIZED prefix => FAIL-CLOSED → mainnet → master-only";; +esac + +hr "done — raw payloads under $OUT_DIR/" diff --git a/onboarding-harness/stage2/buyer-pay.sh b/onboarding-harness/stage2/buyer-pay.sh new file mode 100755 index 0000000..350f27e --- /dev/null +++ b/onboarding-harness/stage2/buyer-pay.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# The "test buyer's wallet": pay a BTCPay invoice on regtest by sending to its +# on-chain address from the regtest bitcoind and mining a confirmation. Used by +# the Stage 2 harness to drive settlement (BTCPay → webhook → Keysat issues the +# license) once the merchant journey has produced a checkout invoice. +# +# Usage: buyer-pay.sh +# Prints the funding txid on success. +set -euo pipefail +BASE="${1:?btcpay base url}"; KEY="${2:?store api key}"; STORE="${3:?store id}"; INV="${4:?invoice id}" +BTND=keysat-btcpay-bitcoind-1 +cli(){ docker exec "$BTND" bitcoin-cli -regtest -rpcuser=keysat -rpcpassword=keysat -rpcport=43782 "$@"; } +# Wallet RPCs must name the wallet explicitly: NBXplorer loads its own wallet, so +# bitcoind has >1 loaded and a bare wallet call errors "Wallet file not specified". +wcli(){ cli -rpcwallet=miner "$@"; } + +# Pull the invoice's on-chain payment address + BTC amount from BTCPay. +PM="$(curl -fsS -H "Authorization: token $KEY" \ + "$BASE/api/v1/stores/$STORE/invoices/$INV/payment-methods")" +ADDR="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].destination // empty')" +AMT="$(echo "$PM" | jq -r '[.[] | select((.paymentMethodId|ascii_upcase)=="BTC-CHAIN" or (.paymentMethodId|ascii_upcase)=="BTC")][0].amount // empty')" +[[ -n "$ADDR" && -n "$AMT" ]] || { echo "no on-chain payment method on invoice $INV" >&2; echo "$PM" >&2; exit 1; } + +# Ensure the miner wallet has spendable coins, then pay + confirm. +cli -named createwallet wallet_name=miner load_on_startup=true >/dev/null 2>&1 || cli loadwallet miner >/dev/null 2>&1 || true +MINE_ADDR="$(wcli getnewaddress)" +cli generatetoaddress 101 "$MINE_ADDR" >/dev/null # generatetoaddress is node-level (no wallet needed) +TXID="$(wcli sendtoaddress "$ADDR" "$AMT")" +cli generatetoaddress 1 "$MINE_ADDR" >/dev/null # 1 conf (BTCPay HighSpeed settles at 0-conf seen / 1-conf) +echo "$TXID" diff --git a/onboarding-harness/stage2/run-stage2.sh b/onboarding-harness/stage2/run-stage2.sh new file mode 100755 index 0000000..ad011f0 --- /dev/null +++ b/onboarding-harness/stage2/run-stage2.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Stage 2 setup: a sandbox Keysat daemon wired to the regtest BTCPay stack, a +# scoped key that can BOTH onboard a catalog AND connect a payment provider +# (merchant-onboard + payment_providers:write), the docs corpus, and a sandbox +# app — then the agent brief for the buyer-pays journey. +# +# Networking: the daemon binds 0.0.0.0 and registers its BTCPay webhook via +# host.docker.internal so the BTCPay *container* can reach it on settle; the +# agent/harness reach the daemon on 127.0.0.1. Sandbox mode + a non-mainnet +# (regtest) store are what let the scoped key connect BTCPay at all. +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib.sh" +require curl; require jq; require openssl; require node +STAGE2_DIR="$HARNESS_DIR/stage2" +BTCPAY_URL="$(grep -h KEYSAT_LIVE_BTCPAY_URL "$STAGE2_DIR/btcpay-regtest/.live-env" 2>/dev/null | cut -d= -f2-)" +BTCPAY_URL="${BTCPAY_URL:-http://127.0.0.1:49392}" + +curl -fsS "$BTCPAY_URL/api/v1/health" >/dev/null 2>&1 \ + || die "regtest BTCPay not reachable at $BTCPAY_URL — run: (cd $STAGE2_DIR/btcpay-regtest && docker compose -p keysat-btcpay up -d)" + +[[ -x "$DAEMON_BIN" ]] || { log "building daemon (cargo build --release)…"; ( cd "$DAEMON_DIR" && cargo build --release >/dev/null ) || die "daemon build failed"; } + +RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-stage2-$$" +RUN_DIR="$RUNS_DIR/$RUN_ID"; mkdir -p "$RUN_DIR/data" "$RUN_DIR/reports" +STATE="$RUN_DIR/state.env"; : > "$STATE" +PORT="$(free_port)"; MASTER="$(openssl rand -hex 32)" +BASE_URL="http://127.0.0.1:$PORT" # agent/harness-facing +PUBLIC_URL="http://host.docker.internal:$PORT" # BTCPay-container-facing (webhooks) + +state_set "$STATE" RUN_ID "$RUN_ID"; state_set "$STATE" RUN_DIR "$RUN_DIR" +state_set "$STATE" PORT "$PORT"; state_set "$STATE" BASE_URL "$BASE_URL" +state_set "$STATE" MASTER_KEY "$MASTER"; state_set "$STATE" BTCPAY_URL "$BTCPAY_URL" + +log "booting sandbox daemon on 0.0.0.0:$PORT (btcpay → $BTCPAY_URL)" +KEYSAT_BIND="0.0.0.0:$PORT" \ +KEYSAT_DB_PATH="$RUN_DIR/data/keysat.db" \ +KEYSAT_ADMIN_API_KEY="$MASTER" \ +KEYSAT_SANDBOX_MODE=1 \ +BTCPAY_URL="$BTCPAY_URL" \ +KEYSAT_PUBLIC_URL="$PUBLIC_URL" \ +KEYSAT_OPERATOR_NAME="Stage 2 Sandbox" \ + nohup "$DAEMON_BIN" >"$RUN_DIR/daemon.log" 2>&1 & +state_set "$STATE" DAEMON_PID "$!" +ln -sfn "$RUN_DIR" "$CURRENT_LINK" +wait_http "$BASE_URL/healthz" 75 || { tail -20 "$RUN_DIR/daemon.log" >&2; die "daemon failed to start"; } + +# Confirm the sandbox flag is actually on (the whole gate depends on it). +[[ "$(curl -fsS -H "Authorization: Bearer $MASTER" "$BASE_URL/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] \ + || die "daemon did not report sandbox mode" + +log "minting scoped key: merchant-onboard + payment_providers:write" +SK="$(curl -fsS -X POST "$BASE_URL/v1/admin/api-keys" -H "Authorization: Bearer $MASTER" \ + -H 'Content-Type: application/json' \ + -d '{"label":"stage2-agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' \ + | jq -r '.token')" +[[ "$SK" == ks_* ]] || die "scoped key mint failed" +state_set "$STATE" MERCHANT_KEY "$SK" + +"$HARNESS_DIR/serve-docs.sh" "$RUN_DIR" >/dev/null +"$HARNESS_DIR/make-sandbox.sh" "$RUN_DIR" >/dev/null +DOCS_URL="$(state_get "$STATE" DOCS_URL)"; SANDBOX="$(state_get "$STATE" SANDBOX)" + +# Two BTCPay store contexts the test buyer/agent can use (regtest store has an +# on-chain wallet; created during de-risk). The agent connects via the scoped +# key; the BTCPay credential it needs is provided as the "operator's BTCPay". +[[ -f "$STAGE2_DIR/btcpay-regtest/.live-env" ]] \ + || die ".live-env missing — run stage2/btcpay-regtest/probe.sh first to mint the BTCPay store token (GATE_TOK_REGTEST)" +source "$STAGE2_DIR/btcpay-regtest/.live-env" + +cat > "$RUN_DIR/AGENT_BRIEF.md" <}**, BTCPay token + **${GATE_TOK_REGTEST:-}** (your "operator's BTCPay" access). +- You were NOT given the master Keysat admin key. If a step seems to need it, that is + either an intended operator-only boundary (note it) or a doc gap (log it). + +## Out of corpus (do not open) +Anything under the Keysat source tree, migrations, tests, or this harness. + +## Output +Write your friction report to \`$RUN_DIR/reports/friction.md\` AND return it as your final +message, in your guide's format. Most-severe-first. On \`completed-clean\`, also emit the +publishable "all the agent had to do was X, Y, Z" walkthrough (secret-free). +EOF + +ok "Stage 2 staged. Run id: $RUN_ID" +cat >&2 <{console.log(s.address().port);s.close();})') +MASTER=$(openssl rand -hex 32) +TMP=$(mktemp -d) +BASE="http://127.0.0.1:$PORT" +pass=0; fail=0 +ok(){ echo " ✅ $*"; pass=$((pass+1)); } +no(){ echo " ❌ $*"; fail=$((fail+1)); } + +echo "== booting sandbox daemon on $BASE (btcpay → $KEYSAT_LIVE_BTCPAY_URL) ==" +KEYSAT_BIND="127.0.0.1:$PORT" \ +KEYSAT_DB_PATH="$TMP/keysat.db" \ +KEYSAT_ADMIN_API_KEY="$MASTER" \ +KEYSAT_SANDBOX_MODE=1 \ +BTCPAY_URL="$KEYSAT_LIVE_BTCPAY_URL" \ +KEYSAT_PUBLIC_URL="$BASE" \ +KEYSAT_OPERATOR_NAME="Stage2 Gate Validation" \ + nohup "$BIN" >"$TMP/daemon.log" 2>&1 & +DAEMON_PID=$! +trap 'kill $DAEMON_PID 2>/dev/null; rm -rf "$TMP"' EXIT +for i in $(seq 1 75); do curl -fsS "$BASE/healthz" >/dev/null 2>&1 && break; sleep 0.2; [[ $i == 75 ]] && { echo "FAIL: daemon never healthy"; tail -20 "$TMP/daemon.log"; exit 1; }; done + +M=(-H "Authorization: Bearer $MASTER") + +echo "== 1. sandbox flag surfaced read-only in /v1/admin/tier ==" +[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/tier" | jq -r '.sandbox')" == "true" ]] && ok "tier.sandbox == true" || no "sandbox flag not surfaced" + +echo "== 2. mint scoped merchant-onboard + payment_providers:write key ==" +SK="$(curl -sS "${M[@]}" -X POST "$BASE/v1/admin/api-keys" -H 'Content-Type: application/json' \ + -d '{"label":"agent","role":"merchant-onboard","scopes":["payment_providers:write"]}' | jq -r '.token')" +[[ "$SK" == ks_* ]] && ok "scoped key minted" || { no "mint failed"; } +S=(-H "Authorization: Bearer $SK") + +# drive a connect: returns HTTP status of the callback. $1=btcpay token +drive_connect(){ + local tok="$1" + local st; st="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')" + [[ -n "$st" && "$st" != null ]] || { echo "000"; return; } + curl -sS -o /tmp/gate-cb.out -w '%{http_code}' -X POST "$BASE/v1/btcpay/authorize/callback?state=$st" \ + --data-urlencode "apiKey=$tok" +} + +echo "== 3. DENY: scoped connect to a no-wallet store (undetermined → fail-closed) ==" +code="$(drive_connect "$GATE_TOK_NOWALLET")" +if [[ "$code" == 400 ]]; then + ok "callback rejected with HTTP 400" + grep -qi "non-mainnet" /tmp/gate-cb.out && ok "rejection cites the non-mainnet restriction" || no "rejection message unexpected: $(cat /tmp/gate-cb.out | head -c200)" +else + no "expected 400, got $code ($(cat /tmp/gate-cb.out | head -c200))" +fi +[[ "$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status" | jq -r '.connected')" == "false" ]] && ok "no provider persisted on deny" || no "a provider was persisted despite deny!" +# The GET callback form (what the agent docs show) must ALSO deny with a 4xx, +# not a 200 error page (regression guard for the GET-handler status fix). +gst="$(curl -sS "${S[@]}" -X POST "$BASE/v1/admin/btcpay/connect" | jq -r '.state')" +gcode="$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/v1/btcpay/authorize/callback?state=$gst&apiKey=$GATE_TOK_NOWALLET")" +[[ "$gcode" == 4* ]] && ok "GET callback form denies with HTTP $gcode (not a 200 error page)" || no "GET callback returned $gcode (expected 4xx)" + +echo "== 4. ALLOW: scoped connect to the regtest store (bcrt1 → non-mainnet) ==" +code="$(drive_connect "$GATE_TOK_REGTEST")" +if [[ "$code" == 200 ]]; then ok "callback succeeded with HTTP 200"; else no "expected 200, got $code ($(cat /tmp/gate-cb.out | head -c300))"; fi +ST_JSON="$(curl -sS "${M[@]}" "$BASE/v1/admin/btcpay/status")" +[[ "$(echo "$ST_JSON" | jq -r '.connected')" == "true" ]] && ok "provider persisted" || no "provider not persisted on allow" +[[ "$(echo "$ST_JSON" | jq -r '.store_id')" == "$KEYSAT_LIVE_BTCPAY_STORE_REGTEST" ]] && ok "persisted store is the regtest store" || no "wrong store persisted: $(echo "$ST_JSON" | jq -c '.store_id')" + +echo "== 5. scoped connect is audited with the resolved network ==" +AUD="$(curl -sS "${M[@]}" "$BASE/v1/admin/audit?action=payment_provider.connect_scoped" | jq -c '.entries[0] // empty')" +echo " audit: $AUD" +echo "$AUD" | grep -qi "regtest" && ok "audit row records network=regtest" || no "audit row missing/!regtest" + +echo +echo "==== RESULT: $pass passed, $fail failed ====" +[[ $fail == 0 ]] || exit 1