13 KiB
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
paymentTxidsend ≥ 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 epoch’s 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.
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):
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]):
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 vin’s 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 announcement’s 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 / outputsdecodeAnnouncement(opReturnHexOrAsm) → fields | nullrequiredSats({ 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 forgetTxData(minimal-slp-wallet / psf-bch-api): page controller address history, decode PSP1 via shared lib, verify controller + chain link, upsert sorted byvalidFromHeight- 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.
- Fetch payment/PoB TX + claim TX (existing).
- If
ACCEPT_LEGACY_PSF_POBand valid SLP + PSF token ID → legacy_getTokenQtyDiff;paymentKind: 'psf-pob'. - 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 toepoch.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'.
- Resolve epoch at
- Else reject.
validateSizeAndPayment:
- Legacy: unchanged PSF write-price × MB × 0.98.
- BCH:
required = requiredSats({ fileSizeBytes, satsPerMb: epoch.satsPerMb })from shared lib; pass ifsatsPaid >= 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-claimwire format unchanged
Docs + tests
- Update
dev-docs/pin-claim-processing.mdfor 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):
GET {pinService}/ipfs/payment-address- Compute required sats from file size + returned
satsPerMb(prefer API values over local guess) - Send BCH to
address(reusesend-bch.jspatterns) - Build existing Pin Claim OP_RETURN with payment txid in the first field (same encoding as today’s PoB txid field)
- Notify
POST /ipfs/pin-claimas 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)
psffpp-payments: deploy Pool+Shares → deployment JSON withpoolAddress.compute-sats-per-mb→ integer rate.announce-registryfrom controller wallet (link previous tip).- Pin-service syncs →
GET /ipfs/payment-addressmatches. - User/CLI pays pool and claims with payment txid.
- Indexer webhooks; pin-service dual-validates and pins.
- 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
- Freeze fixtures in
psffpp-payments(this doc already freezes the byte layout) - Shared registry encode/decode lib + unit fixtures
announce-registry+compute-sats-per-mbCLIs- Pin-service registry sync + APIs + dual validation
- Indexer doc/comment touch-up
file-pin-cli/psffppBCH pay+claim path- 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.