diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b0dfa..ab47bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Run the Shop's marketplace on Synonym's staging with our own Paykit Server v0.1.0-rc11 behind a Cloudflare quick tunnel (`./shop-mixed`, profile `shop-mixed`) - Rebuild the Paykit fixtures on the paykit-rs version a Bitkit pull request pins with `scripts/follow-app-paykit` - Withhold and restore the payment request issuer's endpoints with `POST /endpoints`, so a journey can make the app's request resolution fail and recover - Route the apps' homeserver traffic through `homeserver-proxy`, whose control port (23298) delays or fails one identity's homeserver requests by path diff --git a/README.md b/README.md index 48273fb..b3e193e 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ Every host port is 1024 or above. The ports Bitkit, the Pubky SDK or Bitkit's UI | homeserver-proxy control, homeserver admin (`marketplace`) | 23298, 23288 (`MARKETPLACE_HOMESERVER_ADMIN_PORT`) | 6298, 6288 | | Paykit Server (`marketplace`) | 23101 (`MARKETPLACE_PAYKIT_PORT`) | 3001 | | `fixture-issuer`, `rc56-peer` (`payment-requests`) | 23012, 23013 | 3012, 3013 | +| Paykit Server, quick tunnel metrics (`shop-mixed`) | 23110, 23111 | 3001, 23111 | ## API Examples @@ -610,6 +611,33 @@ The fixture cannot check a Bitkit seller's payout address against the wallet's x Remove the fixture with `./pubky-marketplace down`, or start over with `./pubky-marketplace reset`. The Pubky testnet keeps its accounts in memory, so the fixture cannot restart with its state and `down` deletes it (the regtest chain stays). `./pubky-marketplace --help` lists every command. See [docs/pubky-marketplace.md](docs/pubky-marketplace.md) for the pins, ports, roles and what the fixture does not cover. +#### Shop on Staging with Our Own Paykit Server + +The `shop-mixed` profile runs the marketplace half of the Shop ourselves and everything else on Synonym's staging: Paykit Server +v0.1.0-rc11 (`662dca06`, paykit-rs `ad3c7224` = v0.1.0-rc72, the version the Bitkit send-fix builds pin) on the staging homeserver +`ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy` (`homeserver.staging.pubky.app`), watching Blocktank's staging regtest +Electrum (`ssl://electrs.bitkit.stag0.blocktank.to:9999`, the one Bitkit's staging builds use), behind a Cloudflare quick tunnel +(`https://.trycloudflare.com`, a new name at every start). Bitkit staging builds (Android `devDebug`, iOS `Debug`) are the +seller and the buyer as they are; no local build, `adb reverse` or local chain is needed. Use it when the staging Shop cannot serve a +test, for example when the app pins a Paykit version the staging Paykit Server does not run. Needs Docker, `curl` and `jq`, and +outbound internet. + +```bash +./shop-mixed up # builds on first use (a Rust build), starts, waits until /health/ready answers through the tunnel +./shop-mixed health # pinned revisions, loopback and tunnel /health/ready +./shop-mixed setup-url # seller wallet: open `android` / `ios_url`, or `setup_page` in its browser, approve, then: +./shop-mixed setup-wait +./shop-mixed seller-auth # the marketplace grant on /pub/app.locks/ through httprelay.staging.pubky.app +./shop-mixed purchase --buyer # the Payment Request appears in the buyer wallet +./shop-mixed wait detected # after the buyer pays in the app +./shop-mixed mine # one block on Blocktank's staging regtest chain +./shop-mixed wait confirmed +./shop-mixed down # removes the stack and its state +``` + +The headless seller and buyer of the `marketplace` profile need the local testnet and chain, so `seed`, `fund`, `receive`, `pay`, +`peers` and `verify` refuse here. See [docs/shop-mixed.md](docs/shop-mixed.md) for what runs where and how the pins are checked. + #### Bech32 LNURL Pay - in `Env.{kt,swift}`, use for REGTEST electrum server: `"tcp://localhost:60001"` diff --git a/docker-compose.yml b/docker-compose.yml index 3be982e..11b413c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -618,6 +618,99 @@ services: FIXTURE_PORT: 3013 BITCOIN_RPC_URL: http://bitcoind:43782 + # --------------------------------------------------------------------------- + # Shop on staging with our own Paykit Server (profile "shop-mixed"). Paykit Server v0.1.0-rc11 (662dca06, paykit-rs ad3c7224 = + # v0.1.0-rc72, the version the Bitkit send-fix builds pin) resolves Pubky on mainnet, so it serves Synonym's staging homeserver, + # and watches Blocktank's staging regtest Electrum, the chain of Bitkit's staging builds. A Cloudflare quick tunnel gives it a + # public https URL. The driver plays the marketplace (Locks and the issuer); Bitkit wallets are the seller and the buyer. + # Drive it with ./shop-mixed; see docs/shop-mixed.md. Nothing of the local chain or Pubky testnet runs for it. + # --------------------------------------------------------------------------- + shop-mixed-postgres: + profiles: [shop-mixed] + image: postgres:16-alpine + restart: "no" + environment: + POSTGRES_DB: paykit + POSTGRES_USER: paykit + POSTGRES_PASSWORD: paykit + volumes: + - shop_mixed_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paykit -d paykit"] + interval: 2s + timeout: 5s + retries: 30 + + shop-mixed-paykit: + profiles: [shop-mixed] + image: bitkit-docker/paykit-server:${SHOP_MIXED_PAYKIT_SERVER_TAG:-v0.1.0-rc11} + build: + context: ./marketplace/shop-mixed + dockerfile: paykit-server.Dockerfile + args: + PAYKIT_SERVER_TAG: ${SHOP_MIXED_PAYKIT_SERVER_TAG:-v0.1.0-rc11} + PAYKIT_SERVER_REV: ${SHOP_MIXED_PAYKIT_SERVER_REV:-662dca0619a9aa2962bcd677bd5ddd4563cd2784} + PAYKIT_RS_REV: ${SHOP_MIXED_PAYKIT_RS_REV:-ad3c72248d18587bb5b6ef3c99b063fa9bf31551} + restart: "no" + depends_on: + shop-mixed-postgres: + condition: service_healthy + shop-mixed-driver: + condition: service_started + environment: + PAYKIT_CONFIG: /state/paykit/paykit-server.toml + PAYKIT_DATABASE_URL: postgres://paykit:paykit@shop-mixed-postgres:5432/paykit + volumes: + - shop_mixed_state:/state:ro + ports: + - "127.0.0.1:23110:3001" + # The config and master key are written by `shop-mixed-driver init`, which `up` runs first, as in the marketplace profile. + entrypoint: + - /bin/sh + - -c + - | + until [ -s /state/paykit/paykit-server.toml ] && [ -s /state/paykit/master-key ]; do + echo '[shop-mixed] waiting for shop-mixed-driver init' + sleep 1 + done + PAYKIT_MASTER_KEY="$$(cat /state/paykit/master-key)" + export PAYKIT_MASTER_KEY + exec /usr/local/bin/paykit-server + + # Cloudflare quick tunnel: https://.trycloudflare.com, a new name at every start, no account. Its metrics server + # answers /quicktunnel with the hostname (./shop-mixed url). Built from the pinned cloudflared image, so a project whose + # images are never pulled (the QA lanes) fetches it with `up --build`. + shop-mixed-tunnel: + profiles: [shop-mixed] + image: bitkit-docker/shop-mixed-tunnel:2026.9.3 + build: + context: ./marketplace/shop-mixed + dockerfile: tunnel.Dockerfile + restart: "no" + depends_on: + - shop-mixed-paykit + command: ["tunnel", "--metrics", "0.0.0.0:23111", "--url", "http://shop-mixed-paykit:3001"] + ports: + - "127.0.0.1:23111:23111" + + # The driver's service writes the Paykit config, issuer and master keys (`init`) and stays up, so `up --wait` sees no exited + # one-shot container; ./shop-mixed runs the driver's other commands with `compose run`. + shop-mixed-driver: + profiles: [shop-mixed] + image: bitkit-docker/shop-mixed-driver:local + build: + context: ./marketplace/driver + dockerfile: Dockerfile.staging + environment: + MARKETPLACE_BACKEND: staging + PAYKIT_URL: http://shop-mixed-paykit:3001 + MARKETPLACE_TUNNEL_METRICS: http://shop-mixed-tunnel:23111 + volumes: + - shop_mixed_state:/state + - ./.marketplace/evidence:/evidence + entrypoint: ["node", "/app/driver.mjs"] + command: ["init-and-stay"] + volumes: trezor_emulator_state: trezor_emulator_logs: @@ -629,5 +722,7 @@ volumes: homegate_data: marketplace_postgres_data: marketplace_state: + shop_mixed_postgres_data: + shop_mixed_state: networks: {} diff --git a/docs/shop-mixed.md b/docs/shop-mixed.md new file mode 100644 index 0000000..bbcc7a7 --- /dev/null +++ b/docs/shop-mixed.md @@ -0,0 +1,42 @@ +# Shop on staging with our own Paykit Server + +The `shop-mixed` profile (`./shop-mixed`) is the second Shop route for Bitkit tests. The first is Synonym's staging Shop +(`shop.staging.pubky.app`) as it is deployed; this one replaces only its Paykit Server and marketplace with ours, so a test can run a +Paykit Server version the staging Shop does not. The README section "Shop on Staging with Our Own Paykit Server" has the commands. + +## What runs where + +| Piece | Where | Pin | +| --- | --- | --- | +| Paykit Server | `shop-mixed-paykit`, built from the release tag by `marketplace/shop-mixed/paykit-server.Dockerfile` | `v0.1.0-rc11` = `662dca06`, paykit-rs `ad3c7224` (v0.1.0-rc72), locks-core v0.1.0-rc9, Pubky 0.15.0 | +| Its database | `shop-mixed-postgres` | `postgres:16-alpine` | +| Public URL | `shop-mixed-tunnel`, a Cloudflare quick tunnel to `shop-mixed-paykit:3001` | cloudflared 2026.9.3 | +| Marketplace (Locks and the trusted issuer) | `shop-mixed-driver`, `marketplace/driver/driver.mjs` with `MARKETPLACE_BACKEND=staging` | `@synonymdev/pubky` 0.14.0 | +| Homeserver, HTTP relay | Synonym's staging: `homeserver.staging.pubky.app` (`ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy`), `httprelay.staging.pubky.app` | staging | +| Chain | Blocktank's staging regtest: Electrum `ssl://electrs.bitkit.stag0.blocktank.to:9999`, mining through `api.stag0.blocktank.to/blocktank/api/v2/regtest/chain/mine` | staging | + +The homeserver key and Electrum endpoint are the staging constants of bitkit-android `Env.kt` and bitkit-ios `Env.swift`, so a +staging Bitkit build and this Paykit Server see the same identities and the same chain. Paykit Server resolves Pubky on mainnet +(`[paykit] network = "mainnet"`), where the staging homeserver is published, and uses `bitcoin.network = "regtest"`, which makes its +Android setup link open `to.bitkit.dev`. + +## Pins + +The Paykit Server image is built from `git clone --branch v0.1.0-rc11`, and the build fails when the tag is not at +`SHOP_MIXED_PAYKIT_SERVER_REV` or its `Cargo.lock` does not lock paykit-rs at `SHOP_MIXED_PAYKIT_RS_REV`. The image labels +(`tech.masivo.paykit-server`, `tech.masivo.paykit-server-tag`, `tech.masivo.paykit-rs`, `org.opencontainers.image.revision`) name what +it was built from; `./shop-mixed health` prints them. Move the three `SHOP_MIXED_*` defaults in `docker-compose.yml` together. + +## Config + +`shop-mixed-driver init` (run by the driver service, which then stays up; Paykit Server waits for its files) writes the issuer key, +the master key and the Paykit Server config into the `shop_mixed_state` volume. The config trusts the driver's issuer key under +`[signed_services]`, accepts the setup page from any origin (`allowed_origins = ["*"]`, the page is opened through the tunnel), and +counts one proxy hop (`trusted_proxy_hops = 1`, Cloudflare's `X-Forwarded-For`). + +## Limits + +- Bitkit wallets are the seller and the buyer. The headless roles need the local testnet and bitcoind, so `seed`, `fund`, `receive`, + `pay`, `peers`, `verify` and `verify-bitkit-seller` refuse. +- The quick tunnel gets a new hostname at every start and carries no uptime guarantee; read it with `./shop-mixed url`. +- A staging identity needs a staging invite code at signup, as for any staging test. diff --git a/marketplace/driver/Dockerfile.staging b/marketplace/driver/Dockerfile.staging new file mode 100644 index 0000000..bdd97e5 --- /dev/null +++ b/marketplace/driver/Dockerfile.staging @@ -0,0 +1,8 @@ +# The marketplace driver for the shop-mixed profile (MARKETPLACE_BACKEND=staging). Bitkit wallets play the seller and the buyer on +# staging, so it carries none of the headless helpers the local image copies from the Paykit Server and payment request images. +FROM node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436 +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund +COPY driver.mjs ./ +ENV MARKETPLACE_BACKEND=staging diff --git a/marketplace/driver/driver.mjs b/marketplace/driver/driver.mjs index d49460f..32cd275 100644 --- a/marketplace/driver/driver.mjs +++ b/marketplace/driver/driver.mjs @@ -33,9 +33,20 @@ const EVIDENCE_DIR = '/evidence'; const PAYKIT_URL = process.env.PAYKIT_URL ?? 'http://127.0.0.1:3001'; const RPC_URL = process.env.BITCOIN_RPC_URL ?? 'http://bitcoind:43782'; const RPC_AUTH = `${process.env.BITCOIN_RPC_USER ?? 'polaruser'}:${process.env.BITCOIN_RPC_PASS ?? 'polarpass'}`; -// The static testnet homeserver key is fixed by Pubky Core. -const HOMESERVER = 'pubky8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo'; -const SETUP_ORIGIN = 'http://localhost:8080'; +// MARKETPLACE_BACKEND=staging is the shop-mixed profile (docs/shop-mixed.md): our own Paykit Server on Synonym's staging homeserver and +// relay, watching the regtest chain of Bitkit's staging builds (Blocktank staging Electrum). Bitkit wallets play the seller and the buyer +// there; the headless roles need the local testnet and bitcoind, so their commands refuse. +const STAGING = process.env.MARKETPLACE_BACKEND === 'staging'; +// The static testnet homeserver key is fixed by Pubky Core; staging is homeserver.staging.pubky.app. +const HOMESERVER = STAGING + ? (process.env.MARKETPLACE_HOMESERVER ?? 'pubkyufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy') + : 'pubky8pinxxgqs41n4aididenw5apqp1urfmzdztr8jt4abrkdn435ewo'; +const SETUP_ORIGIN = process.env.MARKETPLACE_SETUP_ORIGIN ?? 'http://localhost:8080'; +// Bitkit's staging builds (bitkit-android Env.kt, bitkit-ios Env.swift): Electrum and the Blocktank regtest API. +const STAGING_ELECTRUM = process.env.MARKETPLACE_ELECTRUM ?? 'ssl://electrs.bitkit.stag0.blocktank.to:9999'; +const BLOCKTANK_REGTEST = 'https://api.stag0.blocktank.to/blocktank/api/v2/regtest'; +// The quick tunnel's metrics server answers /quicktunnel with its public hostname. +const TUNNEL_METRICS = process.env.MARKETPLACE_TUNNEL_METRICS; // The grant client id Paykit Server requires in its config and puts in the setup auth URL as cid. const PAYKIT_CLIENT_ID = 'app.paykit.server'; // Pubky 0.10 sessions are grants, so a signin names its client. @@ -44,8 +55,8 @@ const HEADLESS_CLIENT_ID = 'marketplace.fixture'; // client id as "Requester ID" and the path as the requested permission. const LOCKS_CLIENT_ID = 'locks.app'; const LOCKS_CAPS = '/pub/app.locks/:rw'; -// The testnet's own HTTP relay; wallets reach it on localhost like the homeserver (Android: adb reverse 15412). -const LOCKS_RELAY = 'http://localhost:15412/inbox/'; +// The testnet's own HTTP relay; wallets reach it on localhost like the homeserver (Android: adb reverse 15412). Staging has its own. +const LOCKS_RELAY = STAGING ? 'https://httprelay.staging.pubky.app/inbox/' : 'http://localhost:15412/inbox/'; const BITKIT_SESSION_SECRET = 'bitkit-seller.session'; // Bitkit gives every setup request a fresh BIP84 account, starting at index 1. const STANDIN_ACCOUNT_INDEX = 1; @@ -110,7 +121,8 @@ async function readSecret(name) { async function readFixture() { const fixture = await readJson(FIXTURE_FILE, null); - if (!fixture?.seller) fail('fixture is not seeded; run: ./pubky-marketplace seed'); + if (STAGING && !fixture?.bitkit_seller) fail('no Bitkit seller yet; run: ./shop-mixed seller-auth'); + if (!STAGING && !fixture?.seller) fail('fixture is not seeded; run: ./pubky-marketplace seed'); return fixture; } @@ -231,7 +243,7 @@ async function outboxState() { // ------------------------------------------------------------- Pubky identity -const pubkyClient = () => Pubky.testnet('localhost'); +const pubkyClient = () => (STAGING ? new Pubky() : Pubky.testnet('localhost')); async function signUpIdentity(seed) { const keypair = Keypair.fromSecret(seed); @@ -260,7 +272,7 @@ async function sellerSession(seller) { } // The seller record for `--seller`: the headless seller (default), the Bitkit seller, or its approved pubky. -function pickSeller(fixture, which = 'headless') { +function pickSeller(fixture, which = STAGING ? 'bitkit' : 'headless') { if (which === 'headless') return fixture.seller; const bitkit = fixture.bitkit_seller; if (!bitkit) fail('no Bitkit seller; run: ./pubky-marketplace seller-auth'); @@ -336,7 +348,7 @@ async function init() { await writeFile(`${PAYKIT_DIR}/master-key`, b64url(randomBytes(32)), { mode: 0o644 }); log('created the Paykit Server master key'); } - const config = `[http] + const config = STAGING ? stagingConfig(issuer) : `[http] listen_addr = "0.0.0.0:3001" [locks] @@ -367,6 +379,47 @@ poll_interval = "500ms" out({ status: 'initialized', issuer }); } +// Paykit Server rc11 (paykit-rs rc72): the trusted issuer moved to [signed_services], Pubky resolution is mainnet (the staging +// homeserver is published there), the chain is Blocktank's staging regtest, and the setup page is served through the quick tunnel +// (one Cloudflare hop appends X-Forwarded-For) to any origin a tester opens it from. +function stagingConfig(issuer) { + return `[http] +listen_addr = "0.0.0.0:3001" +trusted_proxy_hops = 1 + +[signed_services] +trusted_public_keys = ["${issuer}"] + +[setup] +allowed_origins = ["*"] + +[paykit] +client_id = "${PAYKIT_CLIENT_ID}" +app_id = "paykit-server" +network = "mainnet" + +[bitcoin] +network = "regtest" + +[electrum] +endpoint = "${STAGING_ELECTRUM}" +poll_interval = "2s" + +[outbox] +poll_interval = "500ms" +`; +} + +async function tunnelUrl() { + if (!TUNNEL_METRICS) return null; + try { + const { hostname } = await (await fetch(`${TUNNEL_METRICS}/quicktunnel`)).json(); + return hostname ? `https://${hostname}` : null; + } catch { + return null; + } +} + async function completeSetup(flowId, seconds = 120) { const deadline = Date.now() + seconds * 1000; for (;;) { @@ -389,8 +442,9 @@ async function beginSetup() { ); if (response.status !== 200) fail(`GET /setup returned HTTP ${response.status}`); const html = await response.text(); - const flow = html.match(/const flowId=("(?:[^"\\]|\\.)*");/); - const auth = html.match(//); + // rc65 writes `const flowId="...";` and ``; rc11 may add attributes after href. + const flow = html.match(/flowId=("(?:[^"\\]|\\.)*")/); + const auth = html.match(/ url && `${url}/setup`) } : {}), + next: `./${STAGING ? 'shop-mixed' : 'pubky-marketplace'} setup-wait ${flowId}`, }); } @@ -541,7 +596,30 @@ function flag(args, name) { return at >= 0 ? args[at + 1] : undefined; } +async function stagingInfo() { + const fixture = await readJson(FIXTURE_FILE, {}); + const purchases = await readJson(PURCHASES_FILE, []); + return { + backend: 'staging', + homeserver: HOMESERVER, + http_relay: LOCKS_RELAY, + electrum: STAGING_ELECTRUM, + paykit_server: { url: await tunnelUrl(), receiver_path: SERVER_PATH, network: 'regtest', ready: (await paykitReady()).ok }, + issuer: existsSync(`${SECRETS}/issuer.seed`) ? await issuerPubky() : null, + bitkit_seller: fixture.bitkit_seller + ? { + pubky: fixture.bitkit_seller.pubky, + marketplace_grant: `${fixture.bitkit_seller.client_id} ${fixture.bitkit_seller.capabilities}`, + setup_completed_at: fixture.bitkit_seller.setup_completed_at ?? null, + } + : null, + purchases: purchases.length, + latest_purchase: purchases.at(-1) ?? null, + }; +} + async function publicInfo() { + if (STAGING) return stagingInfo(); const fixture = await readJson(FIXTURE_FILE, {}); const chain = await chainInfo().catch(() => null); const purchases = await readJson(PURCHASES_FILE, []); @@ -706,9 +784,9 @@ async function recordPaymentTx(purchases, purchase, options) { async function purchase(args) { const sats = Number(flag(args, '--sats') ?? DEFAULT_SATS); if (!Number.isInteger(sats) || sats <= 0) fail('--sats must be a positive integer'); - const buyerArg = flag(args, '--buyer') ?? 'headless'; + const buyerArg = flag(args, '--buyer') ?? (STAGING ? fail('usage: purchase --buyer [--sats N]') : 'headless'); const fixture = await readFixture(); - const seller = pickSeller(fixture, flag(args, '--seller') ?? 'headless'); + const seller = pickSeller(fixture, flag(args, '--seller')); await waitForPaykit(); if (seller.kind !== 'headless') { const setup = await setupStatus(seller.pubky); @@ -846,7 +924,7 @@ async function statusCommand(args) { const { purchases, purchase } = await findPurchase(args[0]); const paykit = await paykitStatus(purchase); // A Bitkit buyer pays from the app, so learn the txid from the chain (non-fatal: nothing to find before payment). - if (!purchase.txid) await recordPaymentTx(purchases, purchase).catch((error) => log(`no payment txid yet: ${error.message}`)); + if (!purchase.txid && !STAGING) await recordPaymentTx(purchases, purchase).catch((error) => log(`no payment txid yet: ${error.message}`)); const before = purchase.state; if (paykit.status === 'confirmed' && paykit.amount_matched && paykit.confirmations >= 1) purchase.state = 'completed'; else if (paykit.status === 'detected' && paykit.amount_matched) purchase.state = 'payment_detected'; @@ -869,7 +947,21 @@ async function statusCommand(args) { }); } +// Staging's chain is Blocktank's: mine through its regtest API, as the Bitkit E2E suite does. +async function stagingMine(args) { + const count = Number(flag(args, '--count') ?? 1); + if (!Number.isInteger(count) || count < 1 || count > 10) fail('--count must be 1 to 10'); + const response = await fetch(`${BLOCKTANK_REGTEST}/chain/mine`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ count }), + }); + if (!response.ok) fail(`Blocktank regtest mine returned HTTP ${response.status}`); + out({ mined: count, via: BLOCKTANK_REGTEST }); +} + async function mine(args) { + if (STAGING) return stagingMine(args); const bundle = flag(args, '--bundle'); const before = await chainInfo(); const mempoolBefore = await rpc('getrawmempool'); @@ -1146,7 +1238,8 @@ async function adoptBitkitSeller(session, kind) { const restored = await pubkyClient().restoreSession(secret); if (restored.info.publicKey.toString() !== pubky) fail('the exported grant session restores to another identity'); await writeSecret(`${SECRETS}/${BITKIT_SESSION_SECRET}`, secret); - const fixture = await readFixture(); + // On staging the Bitkit seller is the first seller the fixture has. + const fixture = STAGING ? await readJson(FIXTURE_FILE, {}) : await readFixture(); const previous = fixture.bitkit_seller?.pubky === pubky ? fixture.bitkit_seller : {}; const paykitSetup = await setupStatus(pubky); fixture.bitkit_seller = { @@ -1168,7 +1261,7 @@ async function sellerAuth(args) { const relay = flag(args, '--relay') ?? LOCKS_RELAY; const seconds = Number(flag(args, '--timeout') ?? 300); if (!Number.isFinite(seconds) || seconds <= 0) fail('usage: seller-auth [--relay ] [--timeout ] [--serial ]'); - await readFixture(); + if (!STAGING) await readFixture(); const { flow, authUrl } = await startLocksGrant(relay); // One compact JSON object per line: the first line is the request, the last one the approval. outLine({ @@ -1275,8 +1368,18 @@ async function verifyBitkitSeller() { await writeEvidence(evidence, '-bitkit-seller'); } +// `init`, then stay up: the shop-mixed profile's long-running driver service, so a project started with `up --wait` holds no +// exited one-shot container. The other commands run in their own `compose run` containers. +async function initAndStay() { + await init(); + process.on('SIGTERM', () => process.exit(0)); + // A pending promise alone does not keep Node running (it exits 13 on an unsettled top-level await); a timer does. + await new Promise(() => setInterval(() => {}, 2 ** 30)); +} + const commands = { init: () => init(), + 'init-and-stay': initAndStay, seed, info: async () => out(await publicInfo()), 'setup-url': (args) => setupUrl(args), @@ -1294,11 +1397,18 @@ const commands = { 'verify-bitkit-seller': () => verifyBitkitSeller(), }; +// The headless wallet roles and the local chain do not exist on staging. +const LOCAL_ONLY = ['seed', 'fund', 'receive', 'pay', 'peers', 'verify', 'verify-bitkit-seller']; + const [command, ...args] = process.argv.slice(2); if (!commands[command]) { process.stderr.write(`unknown driver command: ${command ?? '(none)'}\n`); process.exit(2); } +if (STAGING && LOCAL_ONLY.includes(command)) { + process.stderr.write(`FAIL: ${command} needs the local testnet and chain; on staging Bitkit wallets are the seller and the buyer\n`); + process.exit(2); +} try { await commands[command](args); // A grant flow keeps its relay poll pending; leave once stdout is flushed instead of waiting on it. diff --git a/marketplace/shop-mixed/paykit-server.Dockerfile b/marketplace/shop-mixed/paykit-server.Dockerfile new file mode 100644 index 0000000..7e98ad5 --- /dev/null +++ b/marketplace/shop-mixed/paykit-server.Dockerfile @@ -0,0 +1,31 @@ +# Paykit Server at a release tag, built with the classic builder (the QA boxes' Docker has no BuildKit, so no cache mounts or named +# contexts). The build fails when the tag is not at PAYKIT_SERVER_REV or its Cargo.lock does not lock paykit-rs at PAYKIT_RS_REV. +ARG RUST_IMAGE=rust:1.91.1-slim-bookworm@sha256:8514999d4786ef12efe89239e86b3d0a021b94b9d35108c8efe6c79ca7dc1a65 +ARG RUNTIME_IMAGE=debian:bookworm-20260112-slim@sha256:56ff6d36d4eb3db13a741b342ec466f121480b5edded42e4b7ee850ce7a418ee + +FROM ${RUST_IMAGE} AS builder +ARG PAYKIT_SERVER_TAG +ARG PAYKIT_SERVER_REV +ARG PAYKIT_RS_REV +ENV RUSTUP_TOOLCHAIN=1.91.1 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/* +RUN git clone -q --depth 1 --branch "$PAYKIT_SERVER_TAG" https://github.com/pubky/paykit-server.git /build/paykit-server \ + && test "$(git -C /build/paykit-server rev-parse HEAD)" = "$PAYKIT_SERVER_REV" \ + && grep -Fq "git+https://github.com/pubky/paykit-rs.git?rev=$PAYKIT_RS_REV#$PAYKIT_RS_REV" /build/paykit-server/Cargo.lock +WORKDIR /build/paykit-server +RUN cargo build --locked --release -p paykit-server --bin paykit-server \ + && install -Dm755 target/release/paykit-server /out/paykit-server + +FROM ${RUNTIME_IMAGE} +ARG PAYKIT_SERVER_TAG +ARG PAYKIT_SERVER_REV +ARG PAYKIT_RS_REV +LABEL org.opencontainers.image.revision="${PAYKIT_SERVER_REV}" \ + tech.masivo.paykit-server="${PAYKIT_SERVER_REV}" \ + tech.masivo.paykit-server-tag="${PAYKIT_SERVER_TAG}" \ + tech.masivo.paykit-rs="${PAYKIT_RS_REV}" +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /out/paykit-server /usr/local/bin/paykit-server +RUN groupadd --system --gid 10001 paykit && useradd --system --uid 10001 --gid paykit --create-home paykit +USER paykit:paykit +EXPOSE 3001 diff --git a/marketplace/shop-mixed/tunnel.Dockerfile b/marketplace/shop-mixed/tunnel.Dockerfile new file mode 100644 index 0000000..3b555a9 --- /dev/null +++ b/marketplace/shop-mixed/tunnel.Dockerfile @@ -0,0 +1,2 @@ +# cloudflared 2026.9.3 (arm64 and amd64), the quick tunnel in front of the shop-mixed Paykit Server. +FROM cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c diff --git a/shop-mixed b/shop-mixed new file mode 100755 index 0000000..9620b17 --- /dev/null +++ b/shop-mixed @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Shop on staging with our own Paykit Server (profile "shop-mixed"): Paykit Server v0.1.0-rc11 on Synonym's staging homeserver and +# Blocktank's staging regtest chain, behind a Cloudflare quick tunnel. Docs: docs/shop-mixed.md and the README section "Shop on staging". +set -euo pipefail + +CLI_NAME="$(basename "$0")" +cd "$(dirname "$0")" + +COMPOSE=(docker compose --profile shop-mixed) +SERVICES=(shop-mixed-postgres shop-mixed-driver shop-mixed-paykit shop-mixed-tunnel) + +compose() { "${COMPOSE[@]}" "$@"; } +progress() { echo "[${CLI_NAME}] $*" >&2; } + +# Where the project publishes a container port on loopback (a QA seat's project publishes at its own port block). +published() { compose port "$1" "$2" 2>/dev/null | sed -n 's/.*:\([0-9]*\)$/127.0.0.1:\1/p' | head -1; } + +show_help() { + cat < [args] + +Stack: + build Build Paykit Server v0.1.0-rc11, the driver and the tunnel image + up Start Postgres, Paykit Server and the tunnel; print the public URL and the server's health + url The tunnel's public URL (https://.trycloudflare.com) + health Paykit Server's /health/ready on loopback and through the tunnel, and the image's pinned revisions + logs [service] Follow a service's logs (default shop-mixed-paykit) + down Remove the stack and its volumes + +Marketplace (the driver, MARKETPLACE_BACKEND=staging; Bitkit wallets are the seller and the buyer): + setup-url [--serial S] Start a Paykit setup for the Bitkit seller; prints the grant link and the public setup page + setup-wait Wait for the seller's setup to complete + seller-auth [--timeout N] [--serial S] The marketplace's write grant on the seller's /pub/app.locks/ (staging relay) + purchase --buyer [--sats N] Publish a payment lock and create the invoice for the Bitkit buyer + status [bundle] | wait [seconds] + mine [--count N] Mine on Blocktank's staging regtest chain + info Homeserver, relay, Electrum, the public URL, the seller and the purchases +HELP +} + +driver() { + mkdir -p .marketplace/evidence + compose run --rm --no-deps --quiet-build -T shop-mixed-driver "$@" +} + +url() { + local host + host=$(curl -fsS "http://$(published shop-mixed-tunnel 23111)/quicktunnel" | sed -n 's/.*"hostname":"\([^"]*\)".*/\1/p') + [ -n "$host" ] || { echo "the tunnel has no hostname yet; see: ${CLI_NAME} logs shop-mixed-tunnel" >&2; return 1; } + echo "https://$host" +} + +wait_ready() { + local deadline=$(($(date +%s) + ${1:-180})) base + until curl -fsS -o /dev/null "http://$(published shop-mixed-paykit 3001)/health/ready" 2>/dev/null; do + [ "$(date +%s)" -lt "$deadline" ] || { echo "Paykit Server did not become ready; see: ${CLI_NAME} logs" >&2; exit 1; } + sleep 2 + done + deadline=$(($(date +%s) + ${1:-180})) + until base=$(url 2>/dev/null) && curl -fsS -o /dev/null "$base/health/ready" 2>/dev/null; do + [ "$(date +%s)" -lt "$deadline" ] || { echo "the tunnel does not reach Paykit Server; see: ${CLI_NAME} logs shop-mixed-tunnel" >&2; exit 1; } + sleep 3 + done +} + +health() { + local base image + image=$(compose config --format json | jq -r '.services["shop-mixed-paykit"].image') + echo "image: $image" + docker image inspect --format 'paykit-server {{ index .Config.Labels "tech.masivo.paykit-server" }} ({{ index .Config.Labels "tech.masivo.paykit-server-tag" }}), paykit-rs {{ index .Config.Labels "tech.masivo.paykit-rs" }}' "$image" + echo "loopback: $(curl -fsS "http://$(published shop-mixed-paykit 3001)/health/ready")" + base=$(url) + echo "tunnel: $base" + echo "tunnel /health/ready: $(curl -fsS "$base/health/ready")" +} + +case "${1:-help}" in + build) compose build "${SERVICES[@]}" ;; + up) + mkdir -p .marketplace/evidence + progress "starting Postgres, the driver's init, Paykit Server and the tunnel" + compose up -d --build "${SERVICES[@]}" + progress "waiting for Paykit Server, on loopback and through the tunnel" + wait_ready + health + ;; + url) url ;; + health) health ;; + logs) shift; compose logs -f --tail 100 "${@:-shop-mixed-paykit}" ;; + down) + compose rm -sfv "${SERVICES[@]}" >/dev/null + docker volume rm "$(compose config --format json | jq -r '.name')_shop_mixed_state" "$(compose config --format json | jq -r '.name')_shop_mixed_postgres_data" >/dev/null 2>&1 || true + ;; + init | info | setup-url | setup-wait | seller-auth | purchase | status | wait | mine) driver "$@" ;; + help | -h | --help) show_help ;; + *) show_help >&2; exit 2 ;; +esac