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:
@@ -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
@@ -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/"
|
||||
Reference in New Issue
Block a user