diff --git a/api/ledger_anchor.py b/api/ledger_anchor.py index ded3ee8..f2c2968 100644 --- a/api/ledger_anchor.py +++ b/api/ledger_anchor.py @@ -1,7 +1,10 @@ """Ledger anchoring — content hash computation + CLI-based broadcast to Regen Ledger (mainnet `regen-1`). Computes BLAKE2b-256 hash of canonical claim serialization, derives IRI via -regen CLI, and broadcasts MsgAnchor to the Regen Ledger mainnet. +regen CLI, and broadcasts MsgAnchor / MsgAttest to the Regen Ledger. + +Graph IRI generation (Phase 3): JSON-LD → URDNA2015 canonicalization → BLAKE2b-256 +→ base58check → regen:*.rdf IRI. Mirrors regen-server's generateIRIFromGraph. """ import base64 @@ -143,6 +146,121 @@ def compute_attestation_hash(row) -> str: return h.hexdigest() +# --------------------------------------------------------------------------- +# Graph IRI generation (Phase 3) +# Mirrors regen-server/iri-gen/iri-gen.ts: generateIRIFromGraph +# --------------------------------------------------------------------------- + +# IRI prefix constants from regen-server iri-gen.constants.ts +_IRI_PREFIX_GRAPH = 1 +_GRAPH_CANON_URDNA2015 = 1 +_GRAPH_MERKLE_TREE_UNSPECIFIED = 0 +_DIGEST_ALGORITHM_BLAKE2B_256 = 1 +_IRI_VERSION_0 = 0 + +# Inline JSON-LD context for attestation documents (avoids remote URL fetch) +ATTESTATION_JSONLD_CONTEXT = { + "rfs": "https://framework.regen.network/schema/", + "attestation_rid": "rfs:attestation_rid", + "claim_rid": "rfs:claim_rid", + "reviewer_uri": "rfs:reviewer_uri", + "verdict": "rfs:verdict", + "rationale": "rfs:rationale", + "evidence_uris": {"@id": "rfs:evidence_uris", "@container": "@set"}, +} + + +def _base58check_encode(payload: bytes, version: int) -> str: + """Base58Check encoding (btcsuite-compatible). + + Format: base58(version_byte || payload || checksum) + where checksum = SHA256(SHA256(version_byte || payload))[:4] + """ + versioned = bytes([version]) + payload + checksum = hashlib.sha256(hashlib.sha256(versioned).digest()).digest()[:4] + return _base58_encode(versioned + checksum) + + +def _base58_encode(data: bytes) -> str: + """Pure-Python base58 encoding (Bitcoin alphabet).""" + alphabet = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + n = int.from_bytes(data, "big") + result = bytearray() + while n > 0: + n, remainder = divmod(n, 58) + result.append(alphabet[remainder]) + # Preserve leading zero bytes + for byte in data: + if byte == 0: + result.append(alphabet[0]) + else: + break + return bytes(reversed(result)).decode("ascii") + + +def _content_hash_graph_to_iri(blake2b_hash: bytes) -> str: + """Convert a BLAKE2b-256 hash of graph content to a regen: IRI. + + Pattern: regen:{base58check(concat( + byte(IriPrefixGraph=1), + byte(URDNA2015=1), + byte(MerkleTreeUnspecified=0), + byte(BLAKE2b256=1), + hash + ))}.rdf + + Mirrors regen-server contentHashGraphToIRI. + """ + prefix = bytes([ + _IRI_PREFIX_GRAPH, + _GRAPH_CANON_URDNA2015, + _GRAPH_MERKLE_TREE_UNSPECIFIED, + _DIGEST_ALGORITHM_BLAKE2B_256, + ]) + encoded = _base58check_encode(prefix + blake2b_hash, _IRI_VERSION_0) + return f"regen:{encoded}.rdf" + + +def generate_graph_iri(jsonld_doc: dict) -> str: + """Generate a graph content IRI from a JSON-LD document. + + 1. Canonicalize via URDNA2015 (to n-quads) + 2. Hash with BLAKE2b-256 (32 bytes) + 3. Encode as regen: graph IRI + + Mirrors regen-server generateIRIFromGraph. + Raises ValueError if the document produces empty canonicalization. + """ + from pyld import jsonld + + canonized = jsonld.normalize( + jsonld_doc, + {"algorithm": "URDNA2015", "format": "application/n-quads"}, + ) + if not canonized or not canonized.strip(): + raise ValueError("Invalid JSON-LD document: empty canonicalization") + + blake2b_hash = hashlib.blake2b(canonized.encode("utf-8"), digest_size=32).digest() + return _content_hash_graph_to_iri(blake2b_hash) + + +def build_attestation_jsonld(row) -> dict: + """Build a JSON-LD document for an attestation record. + + Uses inline @context to avoid remote URL fetches during canonicalization. + """ + return { + "@context": ATTESTATION_JSONLD_CONTEXT, + "@type": "rfs:Attestation", + "attestation_rid": row["attestation_rid"], + "claim_rid": row["claim_rid"], + "reviewer_uri": row["reviewer_uri"], + "verdict": row["verdict"], + "rationale": row.get("rationale") or "", + "evidence_uris": sorted(row.get("evidence_uris") or []), + } + + # Cached signing address (deterministic for a given key name) _signing_address: str | None = None @@ -331,6 +449,107 @@ async def broadcast_anchor(claim_rid: str, content_hash: str) -> dict: } +async def broadcast_attest(attestation_rid: str, graph_iri: str, + signer: str | None = None) -> dict: + """Broadcast MsgAttest to Regen Ledger for a graph-native attestation. + + MsgAttest attests to the veracity of anchored graph data. If the data + is not yet anchored, MsgAttest auto-anchors it (one tx instead of two). + + Args: + attestation_rid: The attestation RID (for logging/tracking) + graph_iri: Graph IRI (regen:*.rdf) from generate_graph_iri() + signer: Regen address of the attestor. Defaults to service account. + + Returns dict matching broadcast_anchor() pattern: + ready_to_anchor, ledger_iri, tx_hash, ledger_timestamp, reason + """ + regen_bin = _check_regen_cli() + signer = signer or REGEN_KEY_NAME + + logger.info(f"ledger_anchor.broadcast_attest att={attestation_rid} iri={graph_iri} signer={signer}") + + tx_result = subprocess.run( + [regen_bin, "tx", "data", "attest", graph_iri, + "--from", signer, + "--chain-id", REGEN_CHAIN_ID, + "--node", REGEN_RPC_URL, + "--keyring-backend", "test", + "--fees", "5000uregen", + "--output", "json", + "--yes"], + capture_output=True, text=True, timeout=30, + ) + + if tx_result.returncode != 0: + stderr = tx_result.stderr.strip() + if "key not found" in stderr.lower(): + return { + "attestation_rid": attestation_rid, + "ready_to_anchor": False, + "reason": f"Key '{signer}' not found in keyring.", + "ledger_iri": graph_iri, + "ledger_timestamp": None, + } + if "insufficient funds" in stderr.lower() or "insufficient fee" in stderr.lower(): + return { + "attestation_rid": attestation_rid, + "ready_to_anchor": False, + "reason": f"Insufficient funds for '{signer}'.", + "ledger_iri": graph_iri, + "ledger_timestamp": None, + } + raise RuntimeError(f"Attest broadcast failed: {stderr or tx_result.stdout}") + + tx_data = json.loads(tx_result.stdout) + tx_hash = tx_data.get("txhash") + if not tx_hash: + raise RuntimeError(f"No txhash in broadcast response: {tx_result.stdout}") + + logger.info(f"ledger_anchor.attest_sent att={attestation_rid} txhash={tx_hash}") + + # Poll for tx confirmation (up to 30s) + ledger_timestamp = None + for attempt in range(6): + time.sleep(5) + query_result = subprocess.run( + [regen_bin, "query", "tx", tx_hash, + "--node", REGEN_RPC_URL, + "--output", "json"], + capture_output=True, text=True, timeout=15, + ) + if query_result.returncode == 0: + query_data = json.loads(query_result.stdout) + code = query_data.get("code", -1) + if code == 0: + ledger_timestamp = query_data.get("timestamp") or query_data.get("height") + logger.info(f"ledger_anchor.attest_confirmed att={attestation_rid} txhash={tx_hash}") + break + else: + raise RuntimeError( + f"Attest tx failed on-chain: code={code} log={query_data.get('raw_log', '')}" + ) + + if ledger_timestamp is None: + logger.warning(f"ledger_anchor.attest_timeout att={attestation_rid} txhash={tx_hash}") + return { + "attestation_rid": attestation_rid, + "ready_to_anchor": False, + "reason": f"Attest tx broadcast but confirmation timed out. tx_hash={tx_hash}.", + "ledger_iri": graph_iri, + "ledger_timestamp": None, + "tx_hash": tx_hash, + } + + return { + "attestation_rid": attestation_rid, + "ready_to_anchor": True, + "ledger_iri": graph_iri, + "ledger_timestamp": str(ledger_timestamp) if ledger_timestamp else None, + "tx_hash": tx_hash, + } + + def verify_anchor_onchain(ledger_iri: str) -> bool: """Check if an anchor exists on-chain via the Regen REST API. diff --git a/api/routers/claims_router.py b/api/routers/claims_router.py index fe5b3ed..1485a2b 100644 --- a/api/routers/claims_router.py +++ b/api/routers/claims_router.py @@ -259,6 +259,27 @@ class AttestationAnchorPendingResponse(BaseModel): message: str = "" +class AttestOnChainResponse(BaseModel): + """Response for MsgAttest-based on-chain attestation (Phase 3).""" + attestation_rid: str + claim_rid: str + graph_iri: str + attest_tx_hash: str + attest_timestamp: Optional[str] = None + attestor_address: Optional[str] = None + method: str = "MsgAttest" # distinguishes from legacy MsgAnchor path + + +class AttestOnChainPendingResponse(BaseModel): + attestation_rid: str + claim_rid: str + graph_iri: str + attest_tx_hash: str + status: str = "pending" + message: str = "" + method: str = "MsgAttest" + + class AttestationReconcileResponse(BaseModel): attestation_rid: str claim_rid: str @@ -2459,6 +2480,132 @@ async def anchor_attestation(rid: str, att_rid: str): detail=result.get("reason", "Attestation anchoring not available"), ) + # ------------------------------------------------------------------ # + # MsgAttest — Phase 3 graph-native attestation signing # + # ------------------------------------------------------------------ # + + @router.post("/{rid}/attestations/{att_rid}/attest-onchain", + response_model=AttestOnChainResponse) + async def attest_onchain(rid: str, att_rid: str): + """Attest to an attestation on-chain via MsgAttest (Phase 3). + + Uses graph-native JSON-LD → URDNA2015 → BLAKE2b-256 → graph IRI path. + MsgAttest auto-anchors the graph data if not already anchored. + + Preconditions: + - Attestation exists with approved/rejected verdict + - Parent claim is ledger_anchored + """ + from api.ledger_anchor import ( + broadcast_attest, build_attestation_jsonld, generate_graph_iri, + get_signing_address, + ) + + async with pool.acquire() as conn: + att = await conn.fetchrow(""" + SELECT * FROM claim_attestations + WHERE claim_rid = $1 AND attestation_rid = $2 + """, rid, att_rid) + if not att: + raise HTTPException(status_code=404, detail=f"Attestation not found: {att_rid}") + + claim = await conn.fetchrow( + "SELECT verification FROM claims WHERE claim_rid = $1", rid + ) + if not claim or claim["verification"] != "ledger_anchored": + raise HTTPException( + status_code=409, + detail=f"Parent claim must be ledger_anchored " + f"(current: {claim['verification'] if claim else 'not found'})", + ) + + if att["verdict"] not in ("approved", "rejected"): + raise HTTPException( + status_code=409, + detail=f"Cannot attest with verdict '{att['verdict']}'. " + f"Must be 'approved' or 'rejected'.", + ) + + if att.get("attest_tx_hash"): + raise HTTPException( + status_code=409, + detail=f"Attestation already on-chain (tx_hash: {att['attest_tx_hash'][:16]}...)", + ) + + # Build JSON-LD and generate graph IRI + jsonld_doc = build_attestation_jsonld(att) + graph_iri = generate_graph_iri(jsonld_doc) + + # Resolve attestor address (same 3-tier fallback as MsgAnchor path) + attestor_address = att.get("attestor_address") + if not attestor_address: + async with pool.acquire() as conn: + reviewer_wallet = await conn.fetchval( + "SELECT wallet_address FROM entity_registry WHERE fuseki_uri = $1", + att["reviewer_uri"], + ) + attestor_address = reviewer_wallet + if not attestor_address: + try: + attestor_address = get_signing_address() + except Exception: + attestor_address = None + + # Broadcast MsgAttest (fall back to service account key name) + from api.ledger_anchor import REGEN_KEY_NAME + signer = attestor_address or REGEN_KEY_NAME + try: + result = await broadcast_attest(att_rid, graph_iri, signer=signer) + except Exception as e: + raise HTTPException(status_code=503, detail=f"MsgAttest broadcast failed: {e}") + + tx_hash = result.get("tx_hash") + + if result.get("ready_to_anchor"): + async with pool.acquire() as conn: + await conn.execute(""" + UPDATE claim_attestations + SET attest_tx_hash = $2, ledger_iri = $3, attest_timestamp = NOW(), + attestor_address = $4, updated_at = NOW() + WHERE attestation_rid = $1 + """, att_rid, tx_hash, graph_iri, attestor_address) + + logger.info(f"attestation.attested att={att_rid} iri={graph_iri} tx={tx_hash} method=MsgAttest") + return AttestOnChainResponse( + attestation_rid=att_rid, + claim_rid=rid, + graph_iri=graph_iri, + attest_tx_hash=tx_hash, + attest_timestamp=result.get("ledger_timestamp"), + attestor_address=attestor_address, + ) + + if tx_hash: + async with pool.acquire() as conn: + await conn.execute(""" + UPDATE claim_attestations + SET attest_tx_hash = $2, ledger_iri = $3, attestor_address = $4, updated_at = NOW() + WHERE attestation_rid = $1 + """, att_rid, tx_hash, graph_iri, attestor_address) + from starlette.responses import JSONResponse + return JSONResponse( + status_code=202, + content=AttestOnChainPendingResponse( + attestation_rid=att_rid, + claim_rid=rid, + graph_iri=graph_iri, + attest_tx_hash=tx_hash, + status="pending", + message=result.get("reason", "MsgAttest broadcast but confirmation timed out. " + f"Call POST /claims/{rid}/attestations/{att_rid}/reconcile to finalize."), + ).model_dump(), + ) + + raise HTTPException( + status_code=503, + detail=result.get("reason", "MsgAttest not available"), + ) + @router.post("/{rid}/attestations/{att_rid}/reconcile", response_model=AttestationReconcileResponse) async def reconcile_attestation(rid: str, att_rid: str): """Check on-chain status of an attestation with a pending broadcast. diff --git a/docs/claims/rbam-integration.md b/docs/claims/rbam-integration.md new file mode 100644 index 0000000..92aed1a --- /dev/null +++ b/docs/claims/rbam-integration.md @@ -0,0 +1,327 @@ +# Claims Engine Phase 3, Slice 3 — RBAM Integration for Reviewer Attestation Signing + +**Status:** Design draft (conversation starter for Thursday meeting with Marie) +**Author:** Darren Zal +**Date:** 2026-04-02 +**Prerequisite:** Slice 1 (Graph IRI generation), Slice 2 (MsgAttest broadcast) +**See also:** [V2 Attestation Design](v2-attestations.md), [Identity Integration](identity-integration.md) + +--- + +## 1. Overview — Why RBAM Instead of Raw x/authz + +The March 12 call with Marie confirmed `cosmos.authz` as viable for per-reviewer signing (Option B in identity-integration.md). But the Regen Marketplace has since moved to a higher-level abstraction: **DAO DAO + RBAM** (Role-Based Access Management). + +**Why RBAM over raw `cosmos.authz`:** + +- **Role semantics.** Raw authz grants are address-to-address (`MsgGrant`). RBAM adds role membership (owner, admin, editor, viewer, author), so authorization is "this address has the editor role in this org" rather than "address X granted address Y permission to send MsgAttest." +- **Existing pattern.** The Regen Marketplace already uses this for org management. Reference: `regen-web/web-marketplace/src/legacy-pages/CreateOrganization/hooks/useCreateDao`. We reuse the pattern rather than inventing our own. +- **Authorization filter composition.** RBAM uses typed authorization filters that match specific Cosmos message types. The `can_anchor_attest_data` authorization permits exactly the messages we need. +- **Fee grants are separate.** RBAM handles "who can do what." Fee sponsorship ("who pays gas") is a separate `cosmos.feegrant` concern. Clean separation of authorization from economics. + +**The key insight:** RBAM is not required for the pilot. Individual reviewers can sign MsgAttest directly from their own wallets (Keplr) without any DAO or role setup. RBAM becomes relevant when an organization wants to delegate attestation authority to its members. + +--- + +## 2. Two-Tier Reviewer Identity Model + +### Tier 1 — Individual Path (Pilot) + +Reviewer signs `MsgAttest` directly from their own wallet. No DAO, no RBAM, no delegation. + +**Setup:** +1. Reviewer creates/has a Regen address (Keplr wallet) +2. Reviewer registers wallet with KOI: `POST /entity/{uri}/wallet` (migration 071, already deployed) +3. Service account optionally grants fee allowance (see Section 4) + +**Signing:** +- Reviewer's Keplr wallet signs `MsgAttest` directly +- `attestor` field = reviewer's own `regen1...` address +- On-chain record: "reviewer X attested to content hash Y" + +**Why start here:** +- Zero infrastructure beyond what we have (Keplr + regen CLI) +- Marie confirmed this works (March 12 call) +- Sufficient for dogfooding with 2-5 reviewers +- No dependency on DAO DAO contract deployment + +### Tier 2 — Org-Delegation Path (Later) + +Reviewer has a role in an org DAO (via RBAM) and signs `MsgAttest` on behalf of the org. + +**Setup:** +1. Org creates a DAO via the Marketplace (useCreateDao pattern) +2. DAO assigns roles to members (owner/admin/editor) +3. RBAM contract grants `can_anchor_attest_data` authorization to role holders +4. Reviewer's wallet gains permission to sign `MsgAttest` as a DAO member + +**Signing:** +- Reviewer signs via `MsgExec` wrapping `MsgAttest` +- The DAO address is the logical attestor; the reviewer's address is the executor +- On-chain record binds: org DAO + reviewer address + content hash + +**When this matters:** +- Org-level attestation authority ("CEC reviewed this claim" not just "Dave reviewed this claim") +- Role revocation without revoking individual authz grants +- Audit trail of which role the reviewer held at attestation time + +--- + +## 3. RBAM Authorization Model + +The Marketplace defines a `can_anchor_attest_data` authorization that permits both data anchoring and attestation: + +```json +{ + "filter": { + "$or": [ + { "stargate": { "type_url": "/regen.data.v2.MsgAnchor" } }, + { "stargate": { "type_url": "/regen.data.v2.MsgAttest" } } + ] + } +} +``` + +### Role Inheritance + +Marketplace roles and their authorization levels: + +| Role | Scope | `can_anchor_attest_data` | Notes | +|------|-------|--------------------------|-------| +| **owner** | org | Yes | Full DAO admin | +| **admin** | org | Yes | Can manage members | +| **editor** | org | Yes | Can modify org content | +| **viewer** | org | No | Read-only | +| **author** | project | Yes | Project-level, not org-level | + +Roles with `can_anchor_attest_data` authorization can execute `MsgAnchor` and `MsgAttest` on behalf of the DAO. The RBAM contract checks role membership at execution time -- if a role is revoked, the next MsgExec attempt fails. + +### How It Works at the Contract Level + +1. DAO is created with RBAM as its authorization module +2. RBAM stores role assignments: `(address, role, dao_address)` +3. When a member submits `MsgExec { MsgAttest { ... } }`, the RBAM contract: + - Checks the sender has a role in the DAO + - Checks the role includes `can_anchor_attest_data` authorization + - Checks the inner message type matches the filter (`MsgAnchor` or `MsgAttest`) + - If all pass, executes the inner message with the DAO as the logical sender + +--- + +## 4. Fee Sponsorship + +Fee sponsorship is a **separate concern** from authorization. A reviewer can have RBAM permission to sign `MsgAttest` but still need someone to cover gas fees. + +**Mechanism:** `cosmos.feegrant.v1beta1.MsgGrantAllowance` + +**Two sponsorship patterns:** + +### Pattern A — Service Account Sponsors Individual Reviewers (Pilot) + +``` +Service account (claims-service) → MsgGrantAllowance → Reviewer wallet +``` + +- Covers gas for `MsgAttest` and `MsgAnchor` transactions +- Can set spend limits and expiration +- Works for Tier 1 (individual path) immediately + +### Pattern B — Org DAO Sponsors Members (Later) + +``` +Org DAO → MsgGrantAllowance → Member wallet +``` + +- DAO treasury covers gas for members +- Aligned with RBAM role membership +- Fee grant can be scoped to specific message types + +**Implementation note:** The KOI backend already manages the `claims-service` key. For the pilot, we add a `grant_fee_allowance()` helper alongside the existing `broadcast_anchor()` in `api/ledger_anchor.py`. This is a one-time setup per reviewer, not per-transaction. + +--- + +## 5. Integration Points with KOI Claims Engine + +### 5.1 Entity Registry — wallet_address (Done) + +Migration 071 added `wallet_address TEXT` to `entity_registry` with a unique partial index. The `POST /entity/{uri}/wallet` endpoint validates bech32/EVM addresses and enforces uniqueness. + +Current state: `wallet_address` is populated for the service account. Reviewer wallet registration is the first step of onboarding. + +### 5.2 broadcast_attest() — Signer Address Parameter + +Currently, `broadcast_anchor()` in `api/ledger_anchor.py` hardcodes `--from claims-service` (line 181). For per-reviewer signing: + +**Individual path:** +```python +async def broadcast_attest( + content_hash: str, + signer_address: str, # reviewer's regen1... address + signer_key_name: str = None # if signing via CLI keyring (testing) +) -> dict: +``` + +For Keplr-based signing, the actual transaction construction happens client-side (browser). The backend provides: +- The content hash to attest +- The Graph IRI (from Slice 1's IRI generation) +- The unsigned `MsgAttest` payload for Keplr to sign + +**Org path:** +```python +async def broadcast_attest_as_dao( + content_hash: str, + dao_address: str, # DAO's regen1... address + executor_address: str, # reviewer's regen1... address +) -> dict: +``` + +The executor signs `MsgExec { inner: [MsgAttest { attestor: dao_address, ... }] }`. The RBAM contract validates role membership. + +### 5.3 Graph IRI Generation (Slice 1 Dependency) + +Slice 1 produces the `ContentHash.Graph` IRI for attestation payloads: + +``` +Attestation JSON-LD → URDNA2015 canonicalization → BLAKE2b-256 → base58check → regen:*.rdf IRI +``` + +This IRI is what `MsgAttest.content_hashes` references. Without Slice 1, there is no content hash for RBAM-authorized signers to attest to. + +### 5.4 Attestation Record Binding + +When an attestation is anchored on-chain: + +| Field | Individual Path | Org Path | +|-------|----------------|----------| +| `claim_attestations.attestor_address` | Reviewer's `regen1...` | DAO's `regen1...` | +| `claim_attestations.attest_tx_hash` | Tx hash | Tx hash | +| `claim_attestations.reviewer_uri` | Reviewer entity URI | Reviewer entity URI | +| `claim_attestations.metadata` | `{}` | `{"dao_address": "regen1...", "role": "editor"}` | + +For the org path, the `reviewer_uri` still points to the individual reviewer (the person who made the judgment). The DAO address is the on-chain attestor (the organization endorsing the judgment). Both are recorded. + +--- + +## 6. Sequence Diagrams + +### Individual Reviewer (Tier 1 — Pilot) + +``` +Reviewer KOI Backend Regen Ledger + | | | + | 1. Register wallet | | + | POST /entity/{uri}/ | | + | wallet | | + |--------------------->| | + | | | + | 2. Create attestation | + | POST /claims/{rid}/ | | + | attestations | | + |--------------------->| | + | attestation_rid | | + |<---------------------| | + | | | + | 3. Request unsigned | | + | MsgAttest payload | | + | POST /claims/{rid}/ | | + | attestations/{att}/ | | + | prepare-attest | | + |--------------------->| | + | { msg, graph_iri } | | + |<---------------------| | + | | | + | 4. Sign with Keplr | | + | (client-side) | | + | ~~~~~~~~~~~~~~~~~~~~> | + | | | + | 5. Broadcast signed | | + | tx | | + |--------------------->| MsgAttest | + | |------------------------>| + | | tx_hash | + | |<------------------------| + | | | + | 6. Record on-chain | | + | binding | | + | { tx_hash, iri } | | + |<---------------------| | +``` + +### Org Reviewer via RBAM (Tier 2 — Later) + +``` +Reviewer KOI Backend RBAM Contract Regen Ledger + | | | | + | 1. Register wallet | | | + |--------------------->| | | + | | | | + | 2. Create | | | + | attestation | | | + |--------------------->| | | + | | | | + | 3. Request unsigned | | | + | MsgExec payload | | | + | (wrapping MsgAttest | | | + | with DAO address) | | | + |--------------------->| | | + | { msg, dao_addr } | | | + |<---------------------| | | + | | | | + | 4. Sign MsgExec | | | + | with Keplr | | | + | ~~~~~~~~~~~~~~~~~~~~> | | + | | | | + | 5. Broadcast | | | + |--------------------->| MsgExec{MsgAttest} | | + | |------------------------>| | + | | check role membership | | + | | check authorization | | + | | filter matches | | + | | MsgAttest type_url | | + | | | execute | + | | | MsgAttest | + | | |----------------->| + | | | tx_hash | + | | |<-----------------| + | | tx_hash | | + | |<------------------------| | + | | | | + | 6. Record on-chain | | | + | binding (DAO + | | | + | reviewer) | | | + |<---------------------| | | +``` + +--- + +## 7. Open Questions for Marie + +1. **RBAM contract addresses** — What are the deployed RBAM contract addresses on `regen-1` mainnet and `regen-redwood-1` (or current testnet)? We need these to construct `MsgExec` payloads. + +2. **Minimal DAO for testing** — Is there a lightweight way to create a DAO for development/testing without going through the full Marketplace org creation flow? We need a DAO with 2-3 roles assigned for integration testing. + +3. **Querying RBAM role membership** — Can we query the RBAM contract from the KOI backend to verify a reviewer's role before constructing the MsgExec payload? Specifically: given a `(wallet_address, dao_address)` pair, can we check if the address holds a role with `can_anchor_attest_data` authorization? This would let us fail fast with a clear error rather than submitting a transaction that the contract rejects. + +4. **Recommended testnet** — What's the current recommended testnet for Phase 3 development? `regen-redwood-1`? Is there a faucet? Our mainnet-is-testnet approach works for MsgAnchor (cheap) but RBAM contract interactions may have different cost profiles. + +5. **DAO DAO version compatibility** — Which version of DAO DAO is the Marketplace using? Are there breaking changes between versions we should pin against? + +6. **Role assignment API** — Is there a REST/RPC endpoint to assign roles, or is it contract-execute only? For the pilot, we may want to script role assignment rather than going through the Marketplace UI. + +--- + +## 8. References + +- **useCreateDao hook (Marketplace reference):** `regen-web/web-marketplace/src/legacy-pages/CreateOrganization/hooks/useCreateDao` +- **DAO DAO documentation:** https://docs.daodao.zone/ +- **Cosmos feegrant module:** https://docs.cosmos.network/main/build/modules/feegrant +- **cosmos.authz module:** https://docs.cosmos.network/main/build/modules/authz +- **Regen data module (MsgAttest):** https://buf.build/regen/regen-ledger/docs/main:regen.data.v2 +- **Mintscan proof tx (authz grantee visibility):** https://www.mintscan.io/regen/tx/2cab48df2357f8f0ddb815e7dabadfd656708510ae4351d1b8f44eace2986472?height=20268347 +- **KOI wallet registration:** Migration 071 (`migrations/071_wallet_address.sql`) +- **V2 attestation layer design:** [`docs/claims/v2-attestations.md`](v2-attestations.md) -- Section 8 covers MsgAttest signing model +- **Identity integration (post-call outcomes):** [`docs/claims/identity-integration.md`](identity-integration.md) -- Section 6 covers March 12 confirmed answers + +--- + +*Draft: April 2, 2026 -- for Thursday discussion with Marie, not a final spec.* diff --git a/requirements.txt b/requirements.txt index 4b83539..e055091 100644 --- a/requirements.txt +++ b/requirements.txt @@ -90,6 +90,11 @@ mypy==1.7.1 # ============================================================================= rid-lib==3.2.12 +# ============================================================================= +# Claims Engine Phase 3 — Graph IRI generation (URDNA2015 canonicalization) +# ============================================================================= +pyld>=2.0.4 + # ============================================================================= # TerminusDB Graph Storage (Phase 1) — removed, unused and blocks Python 3.13+ beautifulsoup4==4.13.5 diff --git a/tests/test_graph_iri.py b/tests/test_graph_iri.py new file mode 100644 index 0000000..c456655 --- /dev/null +++ b/tests/test_graph_iri.py @@ -0,0 +1,297 @@ +"""Tests for graph IRI generation and MsgAttest broadcast (Phase 3). + +Verifies: +- URDNA2015 canonicalization → BLAKE2b-256 → base58check → regen:*.rdf IRI +- Graph IRI matches regen-server's generateIRIFromGraph algorithm +- MsgAttest broadcast function structure +- Invalid input handling +""" + +import hashlib +import unittest +from unittest.mock import AsyncMock, patch, MagicMock + +from api.ledger_anchor import ( + ATTESTATION_JSONLD_CONTEXT, + _base58_encode, + _base58check_encode, + _content_hash_graph_to_iri, + build_attestation_jsonld, + generate_graph_iri, +) + + +class TestBase58Encode(unittest.TestCase): + """Test base58 encoding matches Bitcoin/btcsuite implementation.""" + + def test_empty_bytes(self): + result = _base58_encode(b"") + self.assertEqual(result, "") + + def test_single_zero(self): + result = _base58_encode(b"\x00") + self.assertEqual(result, "1") + + def test_known_value(self): + # "Hello" in base58 should be "9Ajdvzr" + result = _base58_encode(b"Hello") + self.assertEqual(result, "9Ajdvzr") + + +class TestBase58CheckEncode(unittest.TestCase): + """Test base58check encoding with version byte + SHA256d checksum.""" + + def test_deterministic(self): + payload = b"\x01\x02\x03" + r1 = _base58check_encode(payload, 0) + r2 = _base58check_encode(payload, 0) + self.assertEqual(r1, r2) + + def test_version_changes_output(self): + payload = b"\x01\x02\x03" + v0 = _base58check_encode(payload, 0) + v1 = _base58check_encode(payload, 1) + self.assertNotEqual(v0, v1) + + +class TestContentHashGraphToIRI(unittest.TestCase): + """Test graph IRI construction from BLAKE2b-256 hash.""" + + def test_produces_rdf_extension(self): + fake_hash = b"\x00" * 32 + iri = _content_hash_graph_to_iri(fake_hash) + self.assertTrue(iri.startswith("regen:")) + self.assertTrue(iri.endswith(".rdf")) + + def test_deterministic(self): + fake_hash = hashlib.blake2b(b"test data", digest_size=32).digest() + iri1 = _content_hash_graph_to_iri(fake_hash) + iri2 = _content_hash_graph_to_iri(fake_hash) + self.assertEqual(iri1, iri2) + + def test_different_hashes_different_iris(self): + h1 = hashlib.blake2b(b"data1", digest_size=32).digest() + h2 = hashlib.blake2b(b"data2", digest_size=32).digest() + iri1 = _content_hash_graph_to_iri(h1) + iri2 = _content_hash_graph_to_iri(h2) + self.assertNotEqual(iri1, iri2) + + +class TestGenerateGraphIRI(unittest.TestCase): + """Test full graph IRI generation from JSON-LD documents.""" + + def test_attestation_jsonld(self): + """Generate graph IRI from a sample attestation JSON-LD doc.""" + doc = { + "@context": ATTESTATION_JSONLD_CONTEXT, + "@type": "rfs:Attestation", + "attestation_rid": "orn:koi-net.attestation:test123", + "claim_rid": "orn:koi-net.claim:claim456", + "reviewer_uri": "orn:koi-net.entity:reviewer1", + "verdict": "approved", + "rationale": "Data verified against source", + "evidence_uris": ["orn:koi-net.entity:ev1"], + } + iri = generate_graph_iri(doc) + self.assertTrue(iri.startswith("regen:")) + self.assertTrue(iri.endswith(".rdf")) + + def test_deterministic_output(self): + """Same document always produces same IRI.""" + doc = { + "@context": ATTESTATION_JSONLD_CONTEXT, + "@type": "rfs:Attestation", + "attestation_rid": "test", + "claim_rid": "test", + "reviewer_uri": "test", + "verdict": "approved", + "rationale": "", + "evidence_uris": [], + } + iri1 = generate_graph_iri(doc) + iri2 = generate_graph_iri(doc) + self.assertEqual(iri1, iri2) + + def test_different_content_different_iri(self): + """Different content produces different IRIs.""" + base = { + "@context": ATTESTATION_JSONLD_CONTEXT, + "@type": "rfs:Attestation", + "attestation_rid": "test", + "claim_rid": "test", + "reviewer_uri": "test", + "verdict": "approved", + "rationale": "", + "evidence_uris": [], + } + doc2 = {**base, "verdict": "rejected"} + iri1 = generate_graph_iri(base) + iri2 = generate_graph_iri(doc2) + self.assertNotEqual(iri1, iri2) + + def test_empty_doc_raises(self): + """Empty/invalid JSON-LD should raise ValueError.""" + with self.assertRaises(ValueError): + generate_graph_iri({}) + + def test_iri_matches_manual_computation(self): + """Cross-check: manually compute the IRI and compare. + + Steps matching regen-server generateIRIFromGraph: + 1. URDNA2015 canonicalize → n-quads string + 2. BLAKE2b-256 of n-quads bytes + 3. Prefix bytes [1, 1, 0, 1] + hash + 4. base58check with version 0 + 5. regen:{result}.rdf + """ + from pyld import jsonld + + doc = { + "@context": ATTESTATION_JSONLD_CONTEXT, + "@type": "rfs:Attestation", + "attestation_rid": "orn:koi-net.attestation:manual-test", + "claim_rid": "orn:koi-net.claim:manual-claim", + "reviewer_uri": "orn:koi-net.entity:manual-reviewer", + "verdict": "approved", + "rationale": "Manual test", + "evidence_uris": [], + } + + # Step 1: canonicalize + nquads = jsonld.normalize( + doc, {"algorithm": "URDNA2015", "format": "application/n-quads"} + ) + self.assertTrue(len(nquads) > 0) + + # Step 2: BLAKE2b-256 + blake_hash = hashlib.blake2b(nquads.encode("utf-8"), digest_size=32).digest() + + # Step 3-5: construct IRI + expected_iri = _content_hash_graph_to_iri(blake_hash) + + # Compare with generate_graph_iri + actual_iri = generate_graph_iri(doc) + self.assertEqual(actual_iri, expected_iri) + + +class TestBuildAttestationJsonLD(unittest.TestCase): + """Test attestation JSON-LD document builder.""" + + def test_required_fields(self): + row = { + "attestation_rid": "att1", + "claim_rid": "claim1", + "reviewer_uri": "reviewer1", + "verdict": "approved", + "rationale": "Looks good", + "evidence_uris": ["ev1", "ev2"], + } + doc = build_attestation_jsonld(row) + self.assertEqual(doc["@type"], "rfs:Attestation") + self.assertIn("@context", doc) + self.assertEqual(doc["attestation_rid"], "att1") + self.assertEqual(doc["evidence_uris"], ["ev1", "ev2"]) + + def test_empty_rationale(self): + row = { + "attestation_rid": "att1", + "claim_rid": "claim1", + "reviewer_uri": "reviewer1", + "verdict": "approved", + "rationale": None, + "evidence_uris": None, + } + doc = build_attestation_jsonld(row) + self.assertEqual(doc["rationale"], "") + self.assertEqual(doc["evidence_uris"], []) + + def test_evidence_sorted(self): + row = { + "attestation_rid": "att1", + "claim_rid": "claim1", + "reviewer_uri": "reviewer1", + "verdict": "approved", + "rationale": "", + "evidence_uris": ["z_ev", "a_ev", "m_ev"], + } + doc = build_attestation_jsonld(row) + self.assertEqual(doc["evidence_uris"], ["a_ev", "m_ev", "z_ev"]) + + +class TestBroadcastAttest(unittest.TestCase): + """Test broadcast_attest() function structure (mocked CLI).""" + + @patch("api.ledger_anchor._check_regen_cli", return_value="/usr/bin/regen") + @patch("api.ledger_anchor.subprocess.run") + def test_happy_path(self, mock_run, mock_cli): + """Successful MsgAttest broadcast + confirmation.""" + import asyncio + + # First call: broadcast + broadcast_response = MagicMock() + broadcast_response.returncode = 0 + broadcast_response.stdout = '{"txhash": "ABCDEF123456"}' + + # Second call: query tx (confirmed) + query_response = MagicMock() + query_response.returncode = 0 + query_response.stdout = '{"code": 0, "timestamp": "2026-04-02T12:00:00Z"}' + + mock_run.side_effect = [broadcast_response, query_response] + + from api.ledger_anchor import broadcast_attest + result = asyncio.get_event_loop().run_until_complete( + broadcast_attest("att-123", "regen:test123.rdf", signer="test-key") + ) + self.assertTrue(result["ready_to_anchor"]) + self.assertEqual(result["tx_hash"], "ABCDEF123456") + self.assertEqual(result["ledger_iri"], "regen:test123.rdf") + + @patch("api.ledger_anchor._check_regen_cli", return_value="/usr/bin/regen") + @patch("api.ledger_anchor.subprocess.run") + def test_key_not_found(self, mock_run, mock_cli): + """Key not found error returns ready_to_anchor=False.""" + import asyncio + + error_response = MagicMock() + error_response.returncode = 1 + error_response.stderr = "Error: key not found" + error_response.stdout = "" + mock_run.return_value = error_response + + from api.ledger_anchor import broadcast_attest + result = asyncio.get_event_loop().run_until_complete( + broadcast_attest("att-123", "regen:test.rdf") + ) + self.assertFalse(result["ready_to_anchor"]) + self.assertIn("key", result["reason"].lower()) + + @patch("api.ledger_anchor._check_regen_cli", return_value="/usr/bin/regen") + @patch("api.ledger_anchor.subprocess.run") + @patch("api.ledger_anchor.time.sleep") # skip actual sleeping + def test_timeout(self, mock_sleep, mock_run, mock_cli): + """Broadcast succeeds but confirmation times out.""" + import asyncio + + broadcast_response = MagicMock() + broadcast_response.returncode = 0 + broadcast_response.stdout = '{"txhash": "TIMEOUT_TX"}' + + # All query attempts fail (tx not found) + query_fail = MagicMock() + query_fail.returncode = 1 + query_fail.stderr = "tx not found" + + mock_run.side_effect = [broadcast_response] + [query_fail] * 6 + + from api.ledger_anchor import broadcast_attest + result = asyncio.get_event_loop().run_until_complete( + broadcast_attest("att-timeout", "regen:timeout.rdf") + ) + self.assertFalse(result["ready_to_anchor"]) + self.assertEqual(result["tx_hash"], "TIMEOUT_TX") + self.assertIn("timed out", result["reason"].lower()) + + +if __name__ == "__main__": + unittest.main()