proof-aware-crypto-tooling-.../src/pacta/treasury.py

197 lines
7.3 KiB
Python
Raw Normal View History

warden hardening round: policy engine, ledger rotation, MCP UX, treasury LIVE, ops docs Tier 2: - request_signature decomposed into named gates (latch, freshness, intent, policy, signer, firewall) - ledger: O(1) tail-read appends under a dedicated lock file (survives rotation rename); hash-chained segment rotation at policy ledger.rotate_at; verify-ledger walks all segments to genesis; archive tampering detected (tested) - docs/threat-model.md (attacker matrix 1-9, proven-vs-trusted, design invariants) + docs/runbook-latch.md (diagnose-first recovery) - lecture 10: executable corrupt-a-member exercise (capsule pin catches one appended byte), honest note on what the pin does NOT stop Lightweight policy engine (POLICY_DENIED wired): - policy.json: per-request/per-day amount ceilings, counterparty allow/deny lists, per-identity overrides; rules make their intent fields mandatory; daily sums from the ledger - Agent UX: - signed refusal receipts travel inside MCP errors (receipt + receipt_path in structuredContent) - airgap over MCP: request_signature signer=airgap + request_id, new airgap_pending tool; park -> list -> device answers -> complete (tested end-to-end) - all 8 tools carry readOnly/destructive annotations - sliding-window rate limiter per tool class (custody/verify/liveness); RATE_LIMITED refusal code; surface control, not ledgered warden-treasury LIVE: - treasury.py: stdlib base58, compact-u16, legacy+v0 wire parsing; every required signature quorum-verified over exact message bytes; completeness gap named in every verdict; RPC fetch uses response as bytes only - - live-quorum test: synthetic Solana tx signed with wallet key -> authentic via 4 proven forks; flipped byte -> not authentic 100 tests green (was 85). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:38:42 +00:00
"""warden-treasury: trust-minimized Solana transaction watching.
An agent that must believe on-chain state ("did my deposit land?") today
asks an RPC provider and trusts the answer. Treasury mode demotes the RPC
from oracle to bandwidth: it takes the raw transaction bytes, parses the
wire format locally (stdlib only), and re-verifies every required
signature through the wallet's quorum - which includes the ``anza``
member, the certificate-covered verify path of the code Solana
validators themselves run. A lying RPC can withhold a transaction, but it
cannot manufacture one the quorum will accept.
Honesty ledger for this module: signature verification is custody-grade
(the quorum); the WIRE PARSING here (base58, compact-u16, the message
header) is ~120 lines of stdlib Python and is trusted base, exactly like
the wire parsers inside the forks are hypotheses of the theorems. A
malicious RPC also controls *which* transactions you see (completeness);
treasury verification establishes authenticity, not completeness.
"""
from __future__ import annotations
import json
import urllib.request
from dataclasses import dataclass, field
from typing import Any
_B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
_B58_INDEX = {c: i for i, c in enumerate(_B58_ALPHABET)}
def b58decode(text: str) -> bytes:
"""Base58 (Bitcoin/Solana alphabet), stdlib only."""
value = 0
for char in text:
if char not in _B58_INDEX:
raise ValueError(f"invalid base58 character {char!r}")
value = value * 58 + _B58_INDEX[char]
raw = value.to_bytes((value.bit_length() + 7) // 8, "big") if value else b""
pad = len(text) - len(text.lstrip("1"))
return b"\x00" * pad + raw
def _compact_u16(data: bytes, offset: int) -> tuple[int, int]:
"""Solana's compact-u16 (shortvec) length encoding."""
result = 0
shift = 0
while True:
if offset >= len(data):
raise ValueError("truncated compact-u16")
byte = data[offset]
offset += 1
result |= (byte & 0x7F) << shift
if byte & 0x80 == 0:
return result, offset
shift += 7
if shift > 14:
raise ValueError("compact-u16 too long")
@dataclass(slots=True)
class ParsedTransaction:
signatures: list[bytes]
message: bytes # the exact bytes the signatures sign
account_keys: list[bytes] # 32-byte Ed25519 keys, signers first
num_required_signatures: int
version: int | None = None # None = legacy, 0 = v0
def parse_transaction(tx: bytes) -> ParsedTransaction:
"""Parse a wire-format Solana transaction (legacy or v0).
Layout: compact-u16 count of 64-byte signatures, then the message.
Message: optional version byte (high bit set), 3-byte header
(num_required_signatures, num_readonly_signed, num_readonly_unsigned),
compact-u16 count of 32-byte account keys, 32-byte recent blockhash,
instructions (not needed here - the signed payload is the whole
message, byte for byte).
"""
count, offset = _compact_u16(tx, 0)
if count == 0 or count > 12:
raise ValueError(f"implausible signature count {count}")
signatures = []
for _ in range(count):
if offset + 64 > len(tx):
raise ValueError("truncated signature section")
signatures.append(tx[offset:offset + 64])
offset += 64
message = tx[offset:]
if not message:
raise ValueError("empty message")
pos = 0
version: int | None = None
if message[0] & 0x80:
version = message[0] & 0x7F
if version != 0:
raise ValueError(f"unsupported transaction version {version}")
pos = 1
if pos + 3 > len(message):
raise ValueError("truncated message header")
num_required = message[pos]
pos += 3
key_count, pos = _compact_u16(message, pos)
if key_count < num_required:
raise ValueError("fewer account keys than required signatures")
keys = []
for _ in range(key_count):
if pos + 32 > len(message):
raise ValueError("truncated account keys")
keys.append(message[pos:pos + 32])
pos += 32
if len(signatures) != num_required:
raise ValueError(
f"signature count {len(signatures)} != header num_required_signatures {num_required}"
)
return ParsedTransaction(signatures, message, keys, num_required, version)
@dataclass(slots=True)
class TreasuryVerdict:
authentic: bool
signer_results: list[dict[str, Any]] = field(default_factory=list)
version: int | None = None
def to_dict(self) -> dict[str, Any]:
return {
"type": "pacta.wallet.treasury_verdict.v1",
"authentic": self.authentic,
"signer_results": self.signer_results,
"transaction_version": self.version,
"note": (
"authenticity only: every required signature was quorum-verified over the "
"message bytes; an RPC can still withhold transactions (no completeness claim)"
),
}
def verify_transaction(wallet: Any, tx: bytes, state_dir: Any = None) -> TreasuryVerdict:
"""Quorum-verify every required signature of a wire-format transaction.
Fail-closed: authentic only if EVERY required signature is a unanimous
quorum accept. Each check lands in the wallet ledger with a treasury
context, so the audit trail shows exactly which chain facts this
wallet chose to believe, and on whose mathematics.
"""
parsed = parse_transaction(tx)
results = []
authentic = True
for i, signature in enumerate(parsed.signatures):
signer_key = parsed.account_keys[i]
outcome = wallet.verify_inbound(
parsed.message,
signature,
signer_key,
context=f"treasury: tx signer {i}",
state_dir=state_dir,
)
results.append({
"signer_index": i,
"signer_key_hex": signer_key.hex(),
"classification": outcome.classification,
"accepted": outcome.accepted,
})
authentic = authentic and outcome.accepted
return TreasuryVerdict(authentic, results, parsed.version)
def fetch_transaction(rpc_url: str, signature_b58: str, timeout: int = 20) -> bytes:
"""Fetch raw transaction bytes from a Solana JSON-RPC endpoint.
The response is used as BYTES ONLY - nothing the RPC says about
status, slots, or balances is consumed here. Trust demotion is the
point: the RPC hands over an envelope; the quorum decides.
"""
import base64
request = json.dumps({
"jsonrpc": "2.0",
"id": 1,
"method": "getTransaction",
"params": [
signature_b58,
{"encoding": "base64", "maxSupportedTransactionVersion": 0},
],
}).encode("utf-8")
req = urllib.request.Request(
rpc_url, data=request, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=timeout) as response:
body = json.loads(response.read())
result = body.get("result")
if not result:
raise ValueError(f"RPC returned no transaction for {signature_b58}")
encoded = (result.get("transaction") or [None])[0]
if not isinstance(encoded, str):
raise ValueError("RPC response has no base64 transaction body")
return base64.b64decode(encoded)