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.
This commit is contained in:
Grant
2026-06-17 09:32:07 -05:00
parent 8eb4a97c6f
commit c673b10a94
9 changed files with 580 additions and 7 deletions
@@ -0,0 +1,2 @@
probe-out/
.live-env
@@ -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.
@@ -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:
+92
View File
@@ -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:-<none>}"
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:-<none>}"
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/"