Skip to content

Add opt-in Observer Protocol pre-sign policy check (fail-closed) for Liquid sends#120

Draft
BTCBoyd wants to merge 4 commits into
jan3dev:developfrom
observer-protocol:op-policy-integration
Draft

Add opt-in Observer Protocol pre-sign policy check (fail-closed) for Liquid sends#120
BTCBoyd wants to merge 4 commits into
jan3dev:developfrom
observer-protocol:op-policy-integration

Conversation

@BTCBoyd

@BTCBoyd BTCBoyd commented Jul 14, 2026

Copy link
Copy Markdown

Add opt-in Observer Protocol pre-sign policy check (fail-closed) for Liquid sends

What this does

Adds an opt-in, fail-closed policy check to the Liquid send path. When enabled via a
single env var (OP_POLICY_ENABLED), the wallet submits the exact unsigned transaction
to the Observer Protocol policy engine and only signs on a verified allow decision.
Deny, non-2xx, an unreachable engine, or a credential whose signature won't verify all
fail closed — the wallet does not sign.

When the flag is off, this code is inert and send() behaves exactly as it does today.
It's additive, env-gated, and adds no wallet dependencies beyond the one noted below.

The path

WalletManager.send()
  └─ extract canonical bytes from the exact unsigned PSET
     └─ POST EvaluationInput → https://api.observerprotocol.org/policy/evaluate
        └─ receive signed PolicyEvaluationCredential (PEC)
           └─ verify PEC signature (eddsa-jcs-2022, did:web:observerprotocol.org#key-3)
              └─ decision == "allow"?  ── no → raise (fail closed), never sign
                 └─ yes → same-PSET identity guard → signer.sign()

The thing that is genuinely real here — and can't be faked — is that a real send is gated
by a real signed credential from the real engine, and the wallet verifies that signature
itself before trusting the decision.

Rail coverage

  • Per-asset spending caps, enforced at the signer boundary — including USDt. Caps are
    selected by asset_id (the cryptographic ground truth of what's moving), not a currency
    label. Validated live against production with the returned credential's signature
    verified: L-BTC within cap → allow, over → deny; USDt within its own cap → allow,
    over → deny
    ; a USDt send with no USDt cap → deny (no-cap-for-asset, not waved
    through the L-BTC cap); an asset unknown to the engine registry → deny
    (unknown-asset). The delegation carries an optional per_rail.liquid.per_asset map
    (context v2); native L-BTC keeps the rail-level cap, so every already-issued delegation
    is unchanged.
  • Lightning funded via Boltz — covered transitively. Confirmed in current code
    (lightning.py): the submarine-swap lockup is an L-BTC wallet_manager.send() routed
    through this same path, so it's evaluated as a Liquid send (counterparty = Boltz swap
    address).
  • Native BTC on-chain — not covered (named gap). bitcoin.py builds a BDK PSBT and
    signs directly, bypassing this hook. Extending the same pre-sign pattern to that path is
    a clean follow-up, called out here rather than left silent.

Verification scope — what the wallet checks, and what it doesn't yet

This is the honest core of the PR, stated plainly:

  • Verified now (this PR): the returned credential's signature and validity window.
    The wallet resolves did:web:observerprotocol.org#key-3 from the published DID document
    and verifies the eddsa-jcs-2022 proof itself. It trusts a signed decision, not the
    transport. If the signature doesn't verify, it fails closed.
  • Not verified yet (named follow-up): the delegation credential's own signature,
    revocation status, and validity window
    . The hosted /policy/evaluate enforces the
    mandate's constraints against the delegation as-provided, but it does not authenticate
    the delegation and does not reject an expired one (confirmed live: an out-of-window
    delegation still evaluates to allow), and this PR doesn't check those either. Full
    caller-side delegation verification (signature + revocation + validFrom/validUntil)
    is the explicit next step. We'd rather name this than ship it silently unhandled under a
    "verify everything" banner.

Fail-closed still holds unconditionally for engine reachability, HTTP status, decision
value, and PEC-signature validity.

Configuration (env-driven)

Variable Default Purpose
OP_POLICY_ENABLED (off) Master switch. 1/true/yes to enable.
OP_DELEGATION_PATH (required when enabled) Path to the delegation credential JSON.
OP_SIDECAR_URL https://api.observerprotocol.org/policy/evaluate Policy engine endpoint.
OP_POLICY_TIMEOUT 10 Request timeout (seconds).
OP_VERIFY_PEC true Verify the returned credential's signature. Off is loud and drops the independence guarantee.
OP_POLICY_UNIT (the sent asset's unit) Optional override. Normally the wallet passes the true unit of the asset being sent (its ticker, from lookup_asset(asset_id)); set this only to force a unit for testing.
OP_DENY_ARTIFACT_DIR ./op-artifacts Where signed deny credentials are persisted.

Fail-closed contract

The wallet signs only on a verified allow. Every one of these refuses to sign:

  • engine unreachable → refuse
  • non-2xx from engine → refuse
  • PEC signature won't verify → refuse
  • PEC expired (validUntil in the past) → refuse
  • decision deny → persist the signed deny artifact, then refuse
  • any other/missing decision → refuse

Plus a same-PSET identity guard: the transaction handed to the signer must be the exact
one that was evaluated.

Dependency note

Zero new dependencies. PEC signature verification needs Ed25519 verify; the vendored
verifier (src/aqua/op_verify.py, self-contained) uses cryptography, which is already a
direct dependency of Aqua (pyproject.toml: cryptography>=42.0.0, used for mnemonic
encryption). Nothing is added to the dependency set.

Explicitly out of scope (deferred)

Native BTC/BDK path; a pluggable pre_sign_hook registry; surfacing config through
config.json (the env gate keeps it honestly "opt-in demo→beta"); delegation transport
polish; seeding the Liquid attestation graph; full caller-side delegation
signature/revocation verification (named above); OP Crossrail (adapters,
crossRailBudget, the cross-rail ledger — orthogonal to this hosted-endpoint path and not
wired into it); the not-yet-enforced advisory rule families; install ergonomics.

Forward-compat: the hook tolerates unknown extra credential fields, so future additive
fields (e.g. a Crossrail budget) won't break this integration.

Files

  • src/aqua/op_policy.py — evaluate-or-raise module (replaces the demo
    op_policy_demo.py, which is removed). Submits the send with the caller-supplied true
    asset unit (no cap-currency relabel — the engine selects the cap by asset_id), verifies
    the returned PEC's signature, persists deny artifacts, logs to stderr, and carries the
    canonical_bytes() PSET-extraction helper.
  • src/aqua/op_verify.py — vendored, self-contained PEC verifier (eddsa-jcs-2022 /
    #key-3), with a float-leaf guard that fail-closes rather than trust the JCS
    approximation outside its proven integer/string range.
  • src/aqua/wallet.py — the pre-sign block in WalletManager.send() plus the import.
  • README — the Observer Protocol section (enable/disable, env vars, fail-closed contract,
    verification scope, rail coverage).

Base

Built as a single additive feature on top of current develop (== main at the time of
writing), targeting develop per the repo's PR convention.

BTCBoyd added 4 commits July 14, 2026 12:15
…Liquid sends

When enabled via OP_POLICY_ENABLED, WalletManager.send() submits the exact unsigned
transaction to the Observer Protocol policy engine and signs only on a verified "allow".
Deny, non-2xx, an unreachable engine, or an unverifiable/expired credential all fail
closed: the wallet does not sign. Inert when the flag is unset, so send() behaves exactly
as before by default.

- src/aqua/op_policy.py: evaluate-or-raise module + canonical_bytes() PSET extraction
- src/aqua/op_verify.py: self-contained PEC verifier (eddsa-jcs-2022, did:web #key-3);
  float-leaf guard fails closed outside the proven JCS integer/string range
- src/aqua/wallet.py: pre-sign block in send() with a same-PSET identity guard
- README: enable/disable, env vars, fail-closed contract, verification scope, rail coverage
- .gitignore: op-artifacts/ (default deny-credential output dir)

Zero new dependencies (cryptography is already required for mnemonic encryption).
…y relabel

The engine now selects the spending cap by asset_id and enforces per-asset, so
the wallet passes the TRUE unit of the asset being sent (its ticker, resolved
from asset_id via Aqua's registry) instead of relabelling the unit to match the
cap currency. That relabel was what let an uncapped asset ride the wrong cap; a
send whose asset has no cap is now refused (engine: no-cap-for-asset), and an
asset unknown to the registry is refused (unknown-asset).

- op_policy.py: remove _resolve_unit / _delegation_cap_unit relabel; pass
  caller-supplied unit through (OP_POLICY_UNIT still overrides for testing)
- wallet.py: resolve the asset ticker via lookup_asset(asset_id, network) and
  pass it as humanReadable.unit; L-BTC (no asset_id) -> "L-BTC"

L-BTC sends are unchanged. Validated end-to-end against the real policy engine:
L-BTC allow, USDt within-cap allow, USDt over-cap deny, USDt no-cap deny
(no-cap-for-asset), unknown-asset deny.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant