Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ struct PayoutAddressClient: Sendable {
case (400, let c): return "Registration rejected (\(c ?? "bad_request"))."
case (401, _): return "Provider access token was rejected. Repair saved access."
case (403, _): return "This provider token cannot register a wallet for that identity."
case (404, _): return "Wallet changes are temporarily unavailable — try again later."
case (409, _): return "Payouts are not yet enabled by the operator for this provider."
case (429, _): return "Too many registration attempts. Wait and try again."
case (503, _): return "Hot-wallet rotation is in progress. Retry later."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ final class PayoutAddressClientTests: XCTestCase {
PayoutAddressClient.userMessage(for: .httpStatus(409, errorCode: "payout_not_allowed"))
.contains("not yet enabled")
)
// 404 registration surface absent (#954)
XCTAssertTrue(
PayoutAddressClient.userMessage(for: .httpStatus(404, errorCode: nil))
.contains("temporarily unavailable")
)
// 503 rotation
XCTAssertTrue(
PayoutAddressClient.userMessage(for: .httpStatus(503, errorCode: "rotation_in_progress"))
Expand Down
54 changes: 42 additions & 12 deletions phase4-coordinator/cmd/coordinator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -893,8 +893,10 @@ func main() {

// SPEC-016 §4.1 — wire the payout package. Migrations + asserts
// run unconditionally so a future flip of payout.enabled does
// not require a schema migration window; the §3.3 handler is
// only mounted on the listener when payout.enabled is true.
// not require a schema migration window. §3.3 challenge/register
// mount whenever hot_wallet_address is set (registration-only
// when payout.enabled=false; #954 / SPEC v0.1.26). Admin payout
// routes and the runner require payout.enabled=true.
// Adapt billingStore to the payout.PayoutClaimer interface — the
// concrete ClaimPayoutReady method satisfies it without modification.
payoutAddresses, payoutMuxHandler, payoutS2, err := setupPayout(context.Background(), reqLogStore.DB(), cfg, tokenStore, billingStore, billingHandler, logger)
Expand All @@ -905,11 +907,11 @@ func main() {
_ = payoutAddresses // satisfies billing.PayoutAddressReader (used by Step 4 reconcile)
if cfg.Auth.RequireProviderTokens {
if payoutMuxHandler != nil {
// Mount payout mux at BOTH /providers/ (for §3.3) and
// /admin/payout/ (for §4.6 abandon + §4.2 run-now).
// Per architect r1 [arch:3.2]: a single /providers/ mount
// makes /admin/payout/* unreachable; mounting at both
// roots lets chi route to the right handler.
// Mount at BOTH /providers/ (§3.3; §7.3 when fully
// enabled) and /admin/payout/ (§6.4.1 pause/resume in
// registration-only; full admin suite when payoutS2 is
// wired). Per architect r1 [arch:3.2]: a single
// /providers/ mount makes /admin/payout/* unreachable.
providerMux.Handle("/providers/", payoutMuxHandler)
providerMux.Handle("/admin/payout/", payoutMuxHandler)
} else {
Expand Down Expand Up @@ -1584,9 +1586,10 @@ func startAuditLogRetentionPruner(ctx context.Context, store requestLogPruner, r
// for the §3.3 endpoint.
//
// When payout.enabled = false the migrations + asserts still
// run (so the schema is ready) but the returned http.Handler
// is nil and the runner does not start. This matches SPEC-016
// §0 "design-only" disposition at v0.1.x.
// run (so the schema is ready) and the runner does not start.
// If payout.security.hot_wallet_address is set, §3.3 handlers
// still mount in registration-only mode (#954 / SPEC v0.1.26);
// otherwise the returned http.Handler is nil.
// payoutStep2 bundles the Step 2 components so main.go can run
// the runner lifecycle alongside the existing shutdown ordering.
// Step 3 extends it with the §4.8a + §4.7 reaper. Step 4 adds the
Expand Down Expand Up @@ -1629,8 +1632,34 @@ func setupPayout(ctx context.Context, db *sql.DB, cfg config.Config, tokenStore
return nil, nil, nil, fmt.Errorf("assert triggers: %w", err)
}
if !cfg.Payout.Enabled {
logger.Info().Msg("payout pipeline disabled (payout.enabled=false); schema applied, handlers idle")
return nil, nil, nil, nil
if strings.TrimSpace(cfg.Payout.Security.HotWalletAddress) == "" {
logger.Info().Msg("payout pipeline disabled (payout.enabled=false); schema applied, handlers idle")
return nil, nil, nil, nil
}
// #954 / SPEC-016 v0.1.26 — registration-only: mount §3.3
// challenge/register + §6.4.1 pause/resume so providers can
// set/change wallets while the execution pipeline stays off.
// No runner, signer, RPC, lease, or execution-only admin routes.
hotWallet := strings.TrimSpace(cfg.Payout.Security.HotWalletAddress)
svc, mux, err := payout.BuildRegistrationOnly(payout.RegistrationOnlyOptions{
DB: db,
HotWallet: hotWallet,
CoolingOff: cfg.Payout.Tuning.AddressCoolingOffPeriod,
Tokens: tokenStore,
Identity: tokenStore,
Fallback: billingFallback,
OperatorKey: cfg.Auth.OperatorKey,
PauseResumeMinInterval: cfg.Payout.Security.PauseResumeMinInterval,
Logger: logger,
})
if err != nil {
return nil, nil, nil, fmt.Errorf("payout registration-only: %w", err)
}
logger.Info().
Str("hot_wallet_address", hotWallet).
Dur("address_cooling_off_period", cfg.Payout.Tuning.AddressCoolingOffPeriod).
Msg("payout registration-only enabled (payout.enabled=false; §3.3 + pause/resume mounted, runner idle)")
return svc, mux, nil, nil
}
sec, err := payout.LoadSecurityConfig(cfg.Payout.Security.HotWalletAddress)
if err != nil {
Expand All @@ -1649,6 +1678,7 @@ func setupPayout(ctx context.Context, db *sql.DB, cfg config.Config, tokenStore
if err := payout.AssertPayoutRuntimeTopology(payout.PayoutRuntimeTopology{
HandlerEnabled: true,
RunnerCoResident: true,
ExecutionEnabled: true,
HotWalletAddressPinned: sec.HotWalletAddress,
LinuxRequired: true,
}); err != nil {
Expand Down
160 changes: 89 additions & 71 deletions phase4-coordinator/dist/check-deploy-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -842,86 +842,104 @@ def g_payout(sub, key):

payout_enabled_raw = g_section(coord, "payout", "enabled")
payout_enabled = (payout_enabled_raw or "").strip().lower() == "true"
if not payout_enabled:
print(" note: payout.enabled is false -> SPEC-016 payout gate SKIPPED")
else:
def get_sec(k): return g_payout("security", k)
def get_tun(k): return g_payout("tuning", k)

def check_payout_field(label, raw, *, hex_64=False, allow_empty=False, is_rpc_url=False):
"""Validate a payout config field: present-with-value or env:NAME.
hot_wallet_raw = (g_payout("security", "hot_wallet_address") or "").strip()
registration_only = (not payout_enabled) and hot_wallet_raw != ""

- missing -> HARD fail
- "env:NAME", NAME unset -> ok (deferred to runtime)
- "env:NAME", NAME set to placeholder -> HARD fail
- "env:" / "env:1bad" -> HARD fail (malformed)
- inline literal placeholder -> HARD fail
- inline literal value -> ok (hex-validated if hex_64;
https / non-internal target
if is_rpc_url -- FULL-r1
[full-sec:r1-1] closure)
"""
if raw is None or raw == "":
if allow_empty:
ok(f"{label} empty -> default applies")
return
hard(f"{label} is MISSING — payout.enabled=true requires every payout.* key")
return
raw_s = str(raw)
src = ""
if raw_s.startswith("env:"):
m = ENV_REF.match(raw_s)
if not m:
hard(f"{label} malformed env indirection {raw_s!r}")
return
name = m.group(1)
resolved = os.environ.get(name)
if not resolved:
ok(f"{label} deferred to runtime via env:{name}")
return
raw_s = resolved
src = f" (resolved from env:{name})"
if PLACEHOLDER.search(raw_s) or raw_s.startswith("<"):
hard(f"{label} is a PLACEHOLDER{src} -> payout pipeline would fail at startup")
return
if hex_64 and not re.fullmatch(r"[0-9a-fA-F]{64}", raw_s):
hard(f"{label} is not 64-hex (len {len(raw_s)}){src}; expected SHA-256 SPKI pin")
return
if is_rpc_url:
err = validate_payout_rpc_url(raw_s)
if err is not None:
hard(f"{label} invalid{src}: {err}")
return
ok(f"{label} present{src}")
def get_sec(k): return g_payout("security", k)
def get_tun(k): return g_payout("tuning", k)

def validate_payout_rpc_url(raw):
# FULL-r1 [full-sec:r1-1] HIGH closure: payout RPC URLs are the
# trust root for the §4.4 two-RPC discipline. Mirror the runtime
# validation in internal/config/config.go::validatePayoutRPCURL —
# reject non-https, userinfo, loopback / private / link-local /
# unspecified IPs. Hostnames pass through (DNS not resolved in
# the deploy gate); the SPKI pin is the runtime trust root.
def validate_payout_rpc_url(raw):
from urllib.parse import urlparse
import ipaddress
try:
u = urlparse(raw.strip())
except Exception as exc:
return f"unparseable URL ({exc})"
if not u.hostname:
return "missing hostname"
if u.scheme != "https":
return f"scheme {u.scheme!r} must be https (SPKI pin only fires on https)"
if u.username or u.password:
return "must not contain userinfo (credentials in URL leak into logs)"
host = u.hostname
try:
ip = ipaddress.ip_address(host)
except ValueError:
return None # hostname literal: defer trust to SPKI pin
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_unspecified:
return f"IP literal {host} is loopback / private / link-local / unspecified (SSRF defense)"
return None
from urllib.parse import urlparse
import ipaddress
try:
u = urlparse(raw.strip())
except Exception as exc:
return f"unparseable URL ({exc})"
if not u.hostname:
return "missing hostname"
if u.scheme != "https":
return f"scheme {u.scheme!r} must be https (SPKI pin only fires on https)"
if u.username or u.password:
return "must not contain userinfo (credentials in URL leak into logs)"
host = u.hostname
try:
ip = ipaddress.ip_address(host)
except ValueError:
return None # hostname literal: defer trust to SPKI pin
if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_unspecified:
return f"IP literal {host} is loopback / private / link-local / unspecified (SSRF defense)"
return None

def check_payout_field(label, raw, *, hex_64=False, allow_empty=False, is_rpc_url=False):
"""Validate a payout config field: present-with-value or env:NAME.

- missing -> HARD fail
- "env:NAME", NAME unset -> ok (deferred to runtime)
- "env:NAME", NAME set to placeholder -> HARD fail
- "env:" / "env:1bad" -> HARD fail (malformed)
- inline literal placeholder -> HARD fail
- inline literal value -> ok (hex-validated if hex_64;
https / non-internal target
if is_rpc_url -- FULL-r1
[full-sec:r1-1] closure)
"""
if raw is None or raw == "":
if allow_empty:
ok(f"{label} empty -> default applies")
return
hard(f"{label} is MISSING — payout.enabled=true requires every payout.* key")
return
raw_s = str(raw)
src = ""
if raw_s.startswith("env:"):
m = ENV_REF.match(raw_s)
if not m:
hard(f"{label} malformed env indirection {raw_s!r}")
return
name = m.group(1)
resolved = os.environ.get(name)
if not resolved:
ok(f"{label} deferred to runtime via env:{name}")
return
raw_s = resolved
src = f" (resolved from env:{name})"
if PLACEHOLDER.search(raw_s) or raw_s.startswith("<"):
hard(f"{label} is a PLACEHOLDER{src} -> payout pipeline would fail at startup")
return
if hex_64 and not re.fullmatch(r"[0-9a-fA-F]{64}", raw_s):
hard(f"{label} is not 64-hex (len {len(raw_s)}){src}; expected SHA-256 SPKI pin")
return
if is_rpc_url:
err = validate_payout_rpc_url(raw_s)
if err is not None:
hard(f"{label} invalid{src}: {err}")
return
ok(f"{label} present{src}")

if registration_only:
# SPEC-016 v0.1.26 §4.1 — §3.3 mounts with payout.enabled=false.
# Validate the hot-wallet pin + cooling-off floor; skip execution-only keys.
print(" note: payout.enabled=false with hot_wallet_address set -> registration-only gate")
check_payout_field("payout.security.hot_wallet_address", hot_wallet_raw)
cooling = get_tun("address_cooling_off_period")
if cooling is None or cooling == "":
hard("payout.tuning.address_cooling_off_period is MISSING — required in registration-only (SPEC-016 §3.1)")
else:
ok("payout.tuning.address_cooling_off_period present")
pause_min = get_sec("pause_resume_min_interval")
if pause_min is None or pause_min == "":
hard("payout.security.pause_resume_min_interval is MISSING — required in registration-only (SPEC-016 §6.4.1)")
else:
ok("payout.security.pause_resume_min_interval present")
elif not payout_enabled:
print(" note: payout.enabled is false and no hot wallet -> SPEC-016 payout gate SKIPPED")
else:
# security namespace (required when enabled=true)
check_payout_field("payout.security.hot_wallet_address", get_sec("hot_wallet_address"))
check_payout_field("payout.security.rpc_url_primary", get_sec("rpc_url_primary"), is_rpc_url=True)
Expand Down
22 changes: 17 additions & 5 deletions phase4-coordinator/dist/coordinator.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -408,14 +408,26 @@ providers:
# operator runbook at dist/payout-runbook.md.

payout:
# Master switch. Schema + handlers initialise at false; runner +
# endpoints do NOT start. Flip to true after every §9 prereq is met.
# Execution-pipeline switch. Schema always initialises. When false
# (default): runner/signer/RPC/lease stay idle. Flip to true only
# after every §9 prereq is met (see dist/payout-runbook.md).
#
# Registration-only (#954 / SPEC-016 v0.1.26): setting a valid
# hot_wallet_address below while enabled=false mounts §3.3
# challenge/register + §6.4.1 pause/resume so providers can set
# wallets without starting the payout runner. Leave empty to keep
# §3.3 unmounted.
enabled: false

security:
# §3.1 hot wallet address (EIP-55 checksummed). Required when
# enabled=true.
hot_wallet_address: "<0x... EIP-55 checksummed hot wallet>"
# §3.1 hot wallet address (EIP-55 checksummed).
# - empty + enabled=false: §3.3 unmounted (handlers idle)
# - set + enabled=false: registration-only (§3.3 + pause/resume)
# - set + enabled=true: full pipeline (also requires RPC URLs,
# encrypted wallet path, caps, etc.)
# Use the FINAL production hot wallet — registrations pin
# registered_against_hot_wallet to this value.
hot_wallet_address: ""

# §4.4 two-RPC URLs. Both required for nonce sync + receipt
# agreement. Use distinct providers (e.g. Infura + Alchemy) so
Expand Down
50 changes: 42 additions & 8 deletions phase4-coordinator/dist/payout-runbook.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Payout pipeline operator runbook (SPEC-016 v0.1.21)
# Payout pipeline operator runbook (SPEC-016 v0.1.26)

This document is the cutover checklist + day-2 operations
guide for the SPEC-016 USDC-on-Base payout pipeline. Every
Expand All @@ -7,14 +7,48 @@ narrative**, the SPEC body is the source of truth on contract.

> **Read order for first-time deploy.**
>
> 1. §1 Hot wallet provisioning + funding (this doc)
> 2. §2 Cap-decision worksheet (this doc, mirrors SPEC §9.3)
> 3. §3 BetterStack synthetic-alert verification (SPEC §9.7
> 1. §0 Registration-only posture (this doc — #954)
> 2. §1 Hot wallet provisioning + funding (this doc)
> 3. §2 Cap-decision worksheet (this doc, mirrors SPEC §9.3)
> 4. §3 BetterStack synthetic-alert verification (SPEC §9.7
> prereq item 6 — required BEFORE cutover)
> 4. §4 Cutover sequence (this doc, mirrors SPEC §9 cutover)
> 5. Day-2: §5 key-rotation runbook (SPEC §6.4 steps 1–5)
> 6. Day-2: §6 SPKI pin rotation (when rotating RPC endpoint certificates)
> 7. Day-2: §7 weekly reconciliation (SPEC §7.4 queries A–F)
> 5. §4 Cutover sequence (this doc, mirrors SPEC §9 cutover)
> 6. Day-2: §5 key-rotation runbook (SPEC §6.4 steps 1–5)
> 7. Day-2: §6 SPKI pin rotation (when rotating RPC endpoint certificates)
> 8. Day-2: §7 weekly reconciliation (SPEC §7.4 queries A–F)

---

## 0. Registration-only posture (#954 / SPEC-016 v0.1.26)

`payout.enabled: false` no longer means "§3.3 is dark." When
`payout.security.hot_wallet_address` is set to the **final**
production EIP-55 address, the coordinator mounts:

- `GET /providers/{id}/payout-address/challenge`
- `POST /providers/{id}/payout-address`
- `POST /admin/payout/pause-registration` /
`POST /admin/payout/resume-registration`

and does **not** start the runner, load the signer/KEK, open
RPC clients, or acquire the payout lease. Leave
`hot_wallet_address: ""` to keep §3.3 unmounted.

**Production remediation for Malibu "Open browser wallet" HTTP 404:**
set the real hot-wallet pin in the Pearl overlay while keeping
`payout.enabled: false`, then restart the coordinator. Confirm
journal line `payout registration-only enabled` and that
`/providers/{id}/payout-address/challenge` returns 401 (not 404)
without a bearer. Use the FINAL production address —
registrations stamp `registered_against_hot_wallet` and a later
rotation strands every prior row until re-registration (§6.4 step 5).

**Kill switch while registration-only:** call pause-registration
(operator key). Do **not** rely on flipping `payout.enabled` —
that flag no longer unmounts §3.3 when the hot wallet is set.

**Rotation ordering (§6.4 step 1):** pause-registration FIRST,
THEN flip `payout.enabled: false` / restart. Pause-before-disable.

---

Expand Down
Loading
Loading