Files
psffpp-payments/docs/infra-changes.md
T

247 lines
13 KiB
Markdown
Raw Normal View History

2026-08-09 10:34:02 -07:00
# PSFFPP Infra Changes (CashScript payments)
Agent handoff plan for integrating CashScript pool payments with PSFFPP: indexer, pin-service, shared registry lib/CLIs, and client tooling.
**Verdict on earlier drafts:** architecture alone (dual-mode validation + pin-service-owned registry) is not enough to build end-to-end. This document freezes protocol bytes, names exact repos/packages/CLIs, and lists validation/API edge cases implementers must not invent ad hoc.
## Locked design decisions
- **Pool address rotation** is expected; historical validation answers: at claim height H, was A a legitimate Pool, and did `paymentTxid` send ≥ required sats to A?
- **Registry:** trusted **controller address** posts append-only OP_RETURN updates; pin-service syncs them by **pulling controller TX history**. Multisig later.
- **Bootstrap:** `POOL_REGISTRY_CONTROLLER` (cashaddr) in pin-service config — only spends from that address may update the registry.
- **Pricing:** each registry announcement bakes **`satsPerMb`** (target ~$0.01/MB at announcement time). Claims in an epoch use that epochs rate — no live oracle on validation/sync.
- **Claims:** reuse existing Pin Claim OP_RETURN; first TXID field is legacy PoB **or** BCH `paymentTxid`. Pin-service **dual-detects**.
- **Registry ownership:** **ipfs-file-pin-service** (not the indexer).
- **Shared encode/decode:** live in **psffpp-payments** (imported by pin-service and CLIs) so announcer and validator never diverge.
```mermaid
flowchart TD
user[User / file-pin-cli] -->|pay BCH| pool[Current PoolContract]
user -->|Pin Claim OP_RETURN| chain[BCH chain]
ops[announce-registry CLI] -->|registry OP_RETURN| chain
ctrl[Controller wallet] --> ops
idx[psf-slp-indexer-g2] -->|detect claim IBD/tip| wh[POST /ipfs/pin-claim]
pin[ipfs-file-pin-service] -->|sync TX history| ctrlAddr[Controller address]
lib[psffpp-payments registry lib] --> pin
lib --> ops
pin -->|build epochs| reg[Append-only registry DB]
wh --> pin
pin -->|dual validate| pay{PoB SLP or payment to active pool?}
pay -->|ok| pinWork[Download size check pin]
```
---
## Handoff checklist (what a fresh agent must build)
| # | Deliverable | Repo |
|---|-------------|------|
| 1 | Frozen registry OP_RETURN encode/decode + fixtures | `psffpp-payments` |
| 2 | CLI: `announce-registry` (controller signs/broadcasts) | `psffpp-payments` |
| 3 | CLI helpers: compute `satsPerMb` from USD+BCH spot (ops convenience only; not used at validation) | `psffpp-payments` |
| 4 | Pin-service: registry sync, Mongo epochs, dual validation, payment-address API | `ipfs-file-pin-service` |
| 5 | Indexer: docs/comments only (claim field semantics) | `psf-slp-indexer-g2` |
| 6 | Client: BCH pay+claim path in `file-pin-cli` (and/or `psffpp` npm) | `file-pin-cli` / `psffpp` |
| 7 | This document + README link | `psffpp-payments` |
Related repos (typical local paths):
- [`psffpp-payments`](../) (this repo)
- [`ipfs-file-pin-service`](../../ipfs/ipfs-file-pin-service)
- [`psf-slp-indexer-g2`](../../slp/psf-slp-indexer-g2)
- [`file-pin-cli`](../../ipfs/file-pin-cli)
---
## Protocol: Pool registry OP_RETURN (frozen)
Lokad ASCII **`PSP1`** (PSFFPP Pool registry v1). Match existing Pin Claim style: OP_RETURN + 4-byte push of lokad.
**scriptPubKey ASM (vout[0]):**
```text
OP_RETURN
OP_PUSHDATA 50535031 # "PSP1"
OP_PUSHDATA 01 # version = 1 (1 byte)
OP_PUSHDATA <poolCashAddrUtf8>
OP_PUSHDATA <validFromHeightLE8>
OP_PUSHDATA <satsPerMbLE8>
OP_PUSHDATA <prevRegistryTxidBin32>
```
**Field rules (do not reinterpret):**
| Field | Bytes | Encoding | Notes |
|-------|-------|----------|-------|
| lokad | 4 | `PSP1` = `50 53 50 31` | Detect: OP_RETURN hex contains `6a0450535031` (same pattern as pin claim `6a0400510000`) |
| version | 1 | `0x01` | Reject unknown versions |
| poolCashAddr | variable | UTF-8 cashaddr **with** `bitcoincash:` prefix, P2SH32 pool deposit address from deploy record | No token-aware address |
| validFromHeight | 8 | **uint64 little-endian** block height | Inclusive; use 8 bytes even if height fits in 4 |
| satsPerMb | 8 | **uint64 little-endian** satoshis per megabyte | Required rate for claims while epoch active |
| prevRegistryTxid | 32 | raw txid bytes in **Bitcoin internal byte order** (same as typically displayed hex reversed vs explorer — implementers: encode/decode with tests against a known fixture hex) | Genesis: 32 zero bytes |
**Detection code path:** same approach as `isPinClaim` in `psf-slp-indexer-g2` (`src/adapters/transaction.js`): read `vout[0].scriptPubKey.hex`, check prefix/includes `6a0450535031`, then `Script.toASM` + split pushes.
**Auth rule:** accept a registry TX only if the **first vins prevout address** equals configured `POOL_REGISTRY_CONTROLLER` (normalized cashaddr). Do not accept “controller somewhere later in vin list” for v1.
**Chain rule:** `prevRegistryTxid` must equal the previous accepted announcements txid (or 32 zero bytes if DB empty / genesis). Reject if tip already has a different child (no forks).
**Epoch interval:** for announcement i with height H_i, address A_i is valid for claims with `claimTx.height` in `[H_i, H_{i+1})`. Latest epoch: `[H_latest, +∞)`.
**Config seed (allowed):** if Mongo empty, pin-service may insert one synthetic genesis epoch from env (`POOL_REGISTRY_SEED_*`) with `registryTxid = "seed"`, `prevRegistryTxid = 00…00`. Prefer **on-chain-only** for production; seed is ops bootstrap for `GET /payment-address` before the first announce. On first successful on-chain sync, treat on-chain tip as authoritative for subsequent validation.
**Shared library location:** `psffpp-payments/lib/pool-registry/` (or `src/pool-registry/`):
- `encodeAnnouncement({ poolCashAddr, validFromHeight, satsPerMb, prevRegistryTxid }) → hex script / outputs`
- `decodeAnnouncement(opReturnHexOrAsm) → fields | null`
- `requiredSats({ fileSizeBytes, satsPerMb }) → ceil(fileSizeBytes / 1e6 * satsPerMb)`
- Unit tests with **fixture vectors** in `psffpp-payments/test/fixtures/registry-opreturn.json`
Pin-service and announce CLI **must** import this lib (path dependency or published workspace package) — do not reimplement decode in pin-service.
---
## `psffpp-payments` CLIs and ops tooling (new)
Contracts already exist; operators still need:
| Script | Purpose |
|--------|---------|
| `scripts/announce-registry.mjs` | Load controller WIF from env (never commit); build PSP1 OP_RETURN + dust+fee inputs; broadcast; print txid + decoded fields |
| `scripts/compute-sats-per-mb.mjs` | Ops helper: inputs USD/MB (default 0.01) + BCH/USD → integer `satsPerMb` (round **up**) |
| Existing `deploy.mjs` / `addresses.mjs` | Unchanged; announce consumes `poolAddress` from deployment JSON |
**Deploy epoch** = compile/instantiate pool → `compute-sats-per-mb``announce-registry` → confirm pin-service `GET /ipfs/payment-address`.
---
## `psf-slp-indexer-g2` changes (minimal)
| Item | Action |
|------|--------|
| `src/adapters/transaction.js` `isPinClaim` | **No decode change.** Comment that `proofOfBurnTxid` may be BCH payment txid **or** legacy PoB. Webhook JSON key stays `proofOfBurnTxid`. |
| `src/adapters/webhook.js` | No change. |
| `src/use-cases/index-blocks.js` | No change to IBD webhook replay. |
| Docs / README | Dual-validation lives in pin-service; indexer does not parse PSP1. |
**Non-goals:** index registry OP_RETURNs; validate payments; expose pool address.
---
## `ipfs-file-pin-service` changes (primary)
### Config (`config/env/common.js`)
- `POOL_REGISTRY_CONTROLLER` (required for BCH path)
- `ACCEPT_BCH_POOL_PAYMENT` (default true when controller set)
- `ACCEPT_LEGACY_PSF_POB` (default true)
- Optional seed: `POOL_REGISTRY_SEED_ADDRESS`, `POOL_REGISTRY_SEED_VALID_FROM`, `POOL_REGISTRY_SEED_SATS_PER_MB`
- `POOL_REGISTRY_SYNC_MS` — timer interval for controller history refresh
- Optional `POOL_REGISTRY_HISTORY_FROM` — block height floor when paging controller history
### Registry adapter + Mongo
- Model fields: `registryTxid`, `poolAddress`, `validFromHeight`, `satsPerMb`, `prevRegistryTxid`, `controllerAddress`, `height` (block height of announcement TX if known), `createdAt`
- `syncFromController()` using the same wallet stack already used for `getTxData` (minimal-slp-wallet / psf-bch-api): page controller address history, decode PSP1 via shared lib, verify controller + chain link, upsert sorted by `validFromHeight`
- Refresh on boot, timer, and once before BCH validation if last sync older than N seconds
- `getEpochAtHeight(H)`, `getCurrentEpoch()`
Follow existing in-repo patterns for `getTxData` / address history — do not invent a new blockchain client.
### Dual validation (`src/use-cases/ipfs.js`)
Keep webhook field name `proofOfBurnTxid`.
1. Fetch payment/PoB TX + claim TX (existing).
2. If `ACCEPT_LEGACY_PSF_POB` and valid SLP + PSF token ID → legacy `_getTokenQtyDiff`; `paymentKind: 'psf-pob'`.
3. Else if `ACCEPT_BCH_POOL_PAYMENT`:
- Resolve epoch at `claimTxDetails.height`. **Default: require confirmed claim height** for BCH path (reject unconfirmed claims for BCH payments).
- Sum `vout[i].value` (sats) where output address **normalizes equal** to `epoch.poolAddress` (use libauth/cashaddr canonicalize; compare P2SH32 payload, not display string alone).
- **Multiple outputs to pool count**; change to other addresses ignored.
- `satsPaid = sum`; `paymentKind: 'bch-pool'`.
4. Else reject.
`validateSizeAndPayment`:
- Legacy: unchanged PSF write-price × MB × 0.98.
- BCH: `required = requiredSats({ fileSizeBytes, satsPerMb: epoch.satsPerMb })` from shared lib; pass if `satsPaid >= floor(required * 0.98)` (same tolerance spirit as legacy).
### Payment matching edge cases (must implement)
- Wrong epoch address at height H → fail.
- Payment txid reuse across claims → follow existing `handleRenewal` / TXID-keyed semantics (do not invent a new reuse policy).
- Payment amount sufficient but claim height before `validFromHeight` → fail.
- Controller history pagination gaps → sync must paginate to genesis of controller or `POOL_REGISTRY_HISTORY_FROM`.
- Address format: canonicalize carefully; never require token-cashaddr.
### Persistence (`src/adapters/localdb/models/pins.js`)
Add `paymentKind`, `satsPaid`, `poolAddress`; keep `proofOfBurnTxid`, `tokensBurned`.
### REST
- `GET /ipfs/payment-address` → current epoch `{ address, validFromHeight, satsPerMb, registryTxid }`
- `GET /ipfs/pool-registry` → full append-only list (ops)
- `POST /ipfs/pin-claim` wire format unchanged
### Docs + tests
- Update `dev-docs/pin-claim-processing.md` for dual path, registry sync, BCH size check.
- Unit tests: decode fixtures, epoch boundaries, dual paths, insufficient sats, wrong pool.
---
## Client / `file-pin-cli` (and `psffpp`) changes
Today `pin-claim-file.js` uploads, burns PSF via `psffpp`, posts claim. Add a **BCH payment mode** (flag or default when payment-address API is available):
1. `GET {pinService}/ipfs/payment-address`
2. Compute required sats from file size + returned `satsPerMb` (prefer API values over local guess)
3. Send BCH to `address` (reuse `send-bch.js` patterns)
4. Build existing Pin Claim OP_RETURN with **payment txid** in the first field (same encoding as todays PoB txid field)
5. Notify `POST /ipfs/pin-claim` as today
Keep legacy PSF burn path behind a flag during dual-mode.
If burn/claim construction lives mainly in npm `psffpp`, update that package similarly and bump the CLI dependency — locate burn helpers inside `psffpp` before duplicating OP_RETURN builders.
---
## Operator workflow (end-to-end)
1. `psffpp-payments`: deploy Pool+Shares → deployment JSON with `poolAddress`.
2. `compute-sats-per-mb` → integer rate.
3. `announce-registry` from controller wallet (link previous tip).
4. Pin-service syncs → `GET /ipfs/payment-address` matches.
5. User/CLI pays pool and claims with payment txid.
6. Indexer webhooks; pin-service dual-validates and pins.
7. Next epoch: new deploy + new announce; historical claims still validate via registry history.
## Genesis / late-sync
Indexer IBD still replays all Pin Claim webhooks. Pin-service syncs **full controller TX history** before/during BCH validation so month-old pool addresses resolve. The payment-address API is never the source of truth for historical checks.
---
## Out of scope
- CashScript cron/GUI; redesigning Pool to avoid address rotation
- Indexer webhook retry queue
- Multisig controller (note as future)
- Live USD/BCH oracle at claim validation time
- Formal PS010 spec merge (recommend follow-up PR)
## Implementation order
1. Freeze fixtures in `psffpp-payments` (this doc already freezes the byte layout)
2. Shared registry encode/decode lib + unit fixtures
3. `announce-registry` + `compute-sats-per-mb` CLIs
4. Pin-service registry sync + APIs + dual validation
5. Indexer doc/comment touch-up
6. `file-pin-cli` / `psffpp` BCH pay+claim path
7. Integration smoke: announce → pay → claim → webhook → pin
## Confidence bar for handoff
A fresh agent with this doc + access to the four repos should **not** need to invent: lokad bytes, integer encodings, which repo owns decode, how clients pay, or how late sync finds old pool addresses. Remaining judgment calls (exact wallet history API method names on minimal-slp-wallet) should be resolved by reading existing pin-service wallet usage — follow `getTxData` / address history patterns already used in-repo.