@observer-protocol/ows-op-verify
v0.1.0
Published
Observer Protocol delegation-credential verifier for the Open Wallet Standard policy engine. Pre-signing enforcement of signed agent mandates as an OWS custom-executable policy.
Maintainers
Readme
ows-op-policy
Observer Protocol delegation verification for Open Wallet Standard wallets.
Status: pre-release. Built and conformance-tested against fixtures; not yet published or validated against production Observer Protocol credentials.
A single-file policy executable for the OWS policy engine
that gates every agent signing request on a signed, revocable, scope-limited
mandate — an ObserverDelegationCredential issued under
AIP v0.8. The agent holds an OWS API
key; the principal holds the mandate; the wallet refuses to sign anything the
mandate doesn't cover.
OWS evaluates policies before the wallet secret's decryption key is derived, so a deny here means key material is never touched. OWS provides the local key-custody and gating machinery; Observer Protocol provides the verifiable authorization provenance — who authorized this agent, for what scope, and whether that authority has been revoked.
agent calls ows sign (API key in the passphrase position)
→ OWS policy engine (AND of all policies on the key)
→ declarative rules: allowed_chains, expires_at (mirror of mandate scope)
→ executable: ows-op-verify (this project)
verifies eddsa-jcs-2022 proof · issuer did:web + assertionMethod
schema allowlist · validity window · revocation status
enforces rails · amount ceilings · counterparty lists · time windows
velocity caps (deny-side) · authorization levels 1/2/3
→ deny ⇒ POLICY_DENIED — the wallet's HKDF key is never derived
→ allow ⇒ decrypt → sign → zeroizeQuickstart
npm install && npm run build # produces dist/ows-op-verify.cjs (zero runtime deps)
npm test # 43 conformance checks, pass AND fail sides
node installer/install.mjs --credential /path/to/delegation-credential.json
ows policy create --file op-delegation.json
ows key create --name "my-agent" --wallet <wallet> --policy op-delegationThe installer copies the executable to ~/.ows/plugins/policies/, derives the
declarative mirror rules (allowed_chains from the mandate's rails,
expires_at from validUntil), writes every behavioral knob into the policy
config explicitly, and self-tests the executable before telling you the exact
ows commands to finish with. A reference policy file is in
policy/op-delegation.template.json.
What gets verified (credential plane)
Proof-suite note (deliberate, not an oversight): this verifier implements the eddsa-jcs-2022 surface as deployed by the Observer Protocol issuance pipeline — the production signing reality. The AIP v0.6–v0.8 draft texts still cite the older
Ed25519Signature2026suite name; aligning the draft language is tracked separately in the AIP repository and is intentionally not part of this project. Where draft text and deployed surface disagree, this verifier follows the deployed surface.
| Check | Behavior |
|---|---|
| Proof suite | DataIntegrityProof / eddsa-jcs-2022 (W3C VC Data Integrity). Legacy Ed25519Signature20xx suites are rejected |
| Issuer | Pinned in config; resolved via did:web; the signing key must be listed in assertionMethod — a key merely present on the DID document is refused |
| Schema | credentialSchema.id must be on the configured allowlist (default: delegation/v2.1.json only) |
| Validity | validFrom ≤ now ≤ validUntil |
| Revocation | W3C Bitstring Status List. Refresh-first: the list is re-fetched on every evaluation; on fetch failure a cached copy younger than revocation.maxStalenessHours (default 24, always written explicitly by the installer) is honored; anything older denies until connectivity returns. The status list credential's own signature is verified against the same pinned issuer |
What gets enforced (transaction plane)
Binding mandate fields deny; advisory fields (per AIP v0.8: cumulative_budget,
allowed_counterparty_types, actionScope.geographic_restriction) are surfaced
in the decision log but never ground a deny. A binding constraint the verifier
cannot establish from the signing context is a deny — wrongful acceptance is
treated as categorically worse than wrongful rejection.
actionScope.allowed_rails,per_transaction_ceiling(same-currency only — no FX conversion, ever),allowed_transaction_categories(against the category declared in config for this key)- Authorization levels:
one-time(exact amount/counterparty/rail/deadline),recurring(per-transaction max, validity, counterparty; period ceilings enforced deny-side via the per-key daily counter),policy(per-rail caps) tradingMandate:maxNotionalPerOrder+unit, counterpartyallowList/blockList(raw addresses, or DIDs viaconfig.counterpartyAddressMap),temporal.allowedTimeWindows(IANA timezones), velocity caps (deny-side, see Limitations), geographic (allowedJurisdictionsOnlyfails closed;blockedJurisdictionsfails open per AIP v0.8 §2.3 — both surfaced in the log)
Per-rail support matrix
The released OWS engine (v1.3.2) hands executables transaction.raw_hex only —
the full serialized transaction (the parsed to/value/data fields in
main-branch docs are newer than the latest release; discovered live-fire, not
from docs, for both EVM and Solana). This verifier decodes the payload
itself, zero runtime dependencies, and does not silently skip what it can't
read:
| Rail | Credential plane¹ | Native amount | Token amount (USDC/USDT) | Counterparty | Net effect |
|---|---|---|---|---|---|
| EVM (eip155:*) | ✅ | ✅ ETH/POL value | ✅ ERC-20 + EIP-3009 transfer* decoded at the token's own decimals | ✅ recipient (to, or the token-transfer to) | Full enforcement |
| Solana (solana:*) | ✅ | ✅ SOL (System transfer) | ✅ SPL TransferChecked (mint-identified, 6-dec) | ✅ wallet (SOL) / token account (SPL)³ | Full enforcement (legacy + v0), within the fail-closed boundaries below |
| Bitcoin, Tron, TON, Cosmos, Sui, XRPL, … | ✅ | ✗ payload unparsed | ✗ | ✗ | Verified-identity only: binding amount/counterparty mandates deny; identity/temporal/revocation-scoped mandates work |
¹ proof, issuer, schema, validity, revocation, time windows, rail allowlists — chain-independent.
EVM payloads: EIP-1559 / EIP-2930 / legacy RLP, with a chainId
cross-check against the signing context. Native value, plus ERC-20
transfer/transferFrom and EIP-3009 transferWithAuthorization /
receiveWithAuthorization (the x402 USDC path) decoded at the token's
registered decimals. Calldata to an unknown contract still denies under a
binding amount constraint unless allowContractCalls: true (native-value-only
measurement, said loudly).
Solana payloads — what is enforced: legacy and v0 (versioned) messages; System-program SOL transfers; SPL Token TransferChecked for USDC/USDT (mint identified from the instruction, amount at 6 decimals, on-chain decimals cross-checked against the registry). Same-currency invariant (no FX), identical to EVM.
Solana payloads — fail-closed boundaries (deny any binding amount/counterparty constraint; stated plainly, not stretched):
- Address Lookup Tables (v0 ALUT): accounts loaded from on-chain tables are not in the static message. No on-chain reads in v1 → deny when a constraint would depend on an ALUT-loaded account.
- Plain SPL
Transfer(non-checked): the mint is not in the instruction, so the asset cannot be proven offline → deny under a token ceiling. UseTransferCheckedfor enforceable token payments. (The asset is never guessed.) - Opaque / unhandled instructions (unknown program, or unhandled
System/Token instruction that could move value) alongside a transfer →
deny: every instruction must satisfy the mandate. Benign
ComputeBudgetandMemoinstructions do not defeat enforcement. - Multiple value transfers in one transaction → deny (attribution unsupported in v1).
- Network binding: a Solana message carries a
recentBlockhash, not the genesis hash, so mainnet-vs-devnet cannot be re-derived from the static payload (unlike EVM's embeddedchainId). The cluster is taken from the PolicyContextchain_idvia the rail map; this is trusted input, documented here rather than faked. (Open question flagged for design review.)
³ SPL transfers move between token accounts, not wallets. The verifier
matches the destination token account address against the mandate's
counterparty list; matching by wallet/DID requires that token account to be
listed (in the allowlist or counterpartyAddressMap). Deriving a token
account's owner offline (associated-token-account derivation) is a planned
extension; until then owner-based SPL counterparty matching fails closed.
Contributing a rail: implement payload parsing for the chain (amount +
recipient), add the CAIP-2 → rail mapping with its family to DEFAULT_RAILS,
and add pass/fail fixtures for every rule the parser enables. PRs are welcome —
the conformance runner (test/run.mjs) is the gate.
Configuration
Everything arrives through the OWS policy file's config object (injected by
the engine as policy_config). No quiet defaults: the installer writes every
behavioral value out loud; the table is the contract.
| Key | Required | Meaning |
|---|---|---|
| credentialPath | ✅ | Delegation credential JSON. Re-read on every call — rotation/re-issue takes effect immediately |
| issuerDid | ✅ | Pinned trusted issuer. Credentials from any other issuer deny |
| schemaAllowlist | ✅ | Accepted credentialSchema.id URLs (frozen-URL schema policy; default v2.1 only) |
| agentDid | — | Pin credentialSubject.id; mismatch denies |
| revocation.maxStalenessHours | written explicitly (24) | Cache window for status lists when refresh fails. Older ⇒ deny |
| revocation.onUnreachable | written explicitly | cache-then-deny (the only implemented behavior, on purpose) |
| revocation.fetchTimeoutMs | written explicitly (1500) | Per-fetch budget inside OWS's hard 5s executable timeout |
| didCache.maxStalenessHours | written explicitly (24) | Same refresh-first policy for issuer DID documents |
| rails | defaults built in | CAIP-2 → {rail, currency, decimals, family} map; extend/override per deployment |
| evmTokens | defaults built in | lowercased ERC-20 contract → {symbol, decimals} (USDC/USDT preloaded); identifies token transfers |
| solanaMints | defaults built in | base58 SPL mint → {symbol, decimals} (USDC/USDT preloaded) |
| allowContractCalls | default false | Permit unknown EVM contract calldata under binding amount constraints, measured by native value only (read the footnote first) |
| transactionCategory | — | Category this key's transactions are declared as, matched against allowed_transaction_categories |
| counterpartyAddressMap | — | DID → addresses, for mandates that pin counterparties by DID (use token-account addresses for SPL) |
| cacheDir / auditLog | written explicitly | Cache location; JSONL decision log |
| offline.didDocumentPath / offline.statusListPath | — | Air-gapped/test overrides; bypass network entirely |
Decision log
Every evaluation appends one JSON line — timestamp, verdict, reason, advisory
notes, chain, wallet/key ids, credential id + SHA-256, transaction hash — to
auditLog (0600). The log is unsigned in this release; emission of signed
PolicyEvaluationCredentials with a published per-instance key is a planned
extension (see docs/SCOPE.md).
Limitations (read these — they are load-bearing)
- Counterparty DIDs need a mapping. The wallet sees addresses, not DIDs. A
mandate pinning a counterparty DID with no
counterpartyAddressMapentry denies (the binding cannot be established). requireIssuerClassIndenies. Verifying a counterparty's attested issuer class needs an attestation source this executable doesn't have yet.- SPL counterparty matching is against the token account, not the wallet owner. Offline associated-token-account derivation is a planned extension; until then, list token-account addresses in the allowlist or fail closed.
- Solana v0 ALUT and plain SPL
Transferfail closed under binding amount/counterparty constraints (no on-chain reads; mint not in a plain Transfer). See the support matrix for the full boundary list. - Velocity/period ceilings are deny-side only. The only state OWS provides is a per-API-key, per-calendar-day, native-value counter — a lower bound on any rolling window. An overshoot it can see is a real overshoot (deny); full allow-side accounting needs a stateful evaluator.
- One-time credentials aren't consumed. Single-use semantics can't be tracked at this layer; revoke the credential after settlement.
- Order-plane constraints are not enforced here.
allowedVenues,allowedInstruments,dailyDrawdownCaprequire order context and belong to an order-aware evaluator; they are surfaced as NOT-ENFORCED notes. - Schema note: the frozen v2.1 schema's
proofblock predates the eddsa-jcs-2022 migration; the credential body is validated against v2.1 structure while the proof is verified per W3C VC Data Integrity. Tracked as a spec-alignment item in the AIP repository.
Development
npm run typecheck # strict TS
npm run build # esbuild → dist/ows-op-verify.cjs (single file, node:* only)
npm test # fixtures regenerated fresh (keys generated on the fly), 43 checks
npm run livefire # end-to-end against a real local OWS install (see harness/)MIT. Spec surface: observer-protocol/op-policy-engine · observer-protocol/aip.
