Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,30 @@ nandatown run auction --seed 7
| capped_influence_uncapped_control | the same scenario with trust left at reputation.v1: the run FAILS on purpose, showing that one voice that never trades can bury an honest seller |
| registry_eviction | a rival republishes the honest seller's card under its name with the wrong key; with `index.owned.v1` the forged card is refused, the honest listing survives, and the honest seller is paid |
| registry_eviction_unowned | the same scenario on the default `index.v1`: the forged card replaces the verified one, the buyer only finds the rival, and the run FAILS on purpose |
| sealed_delivery | payments on hashlock.v1: sealed datasets survive a lost box, a damaged box, wrong content and a late key claim; a seller is paid exactly when its buyer opens the listed data |
| sealed_delivery_no_hashlock | the same market on deadline.v1: the seller sends the key itself, the late key opens goods whose payment was refunded, and the run FAILS on purpose |

Every scenario also gets two standing checks: the ledger conserved money across every movement, and no redacted field leaked into the exported records.

`payments: hashlock.v1` makes paying for digital goods and receiving them one step, so neither side can end up with both the money and the goods.

- The seller's card lists a digest of the data. The seller sends the data sealed with AES-GCM under a fresh key.
- The buyer pays only while holding a box that matches its digest and the listing. The escrow is locked to the payee, the key's digest and the listed content.
- The seller is paid only by handing the key to the ledger. The ledger pays only if all of these hold:
- the claim is before the deadline,
- the key matches its digest,
- the box decrypts,
- the plaintext matches the listing.
- The same step reveals the key to the buyer. Otherwise the deadline refunds the buyer, who keeps a box it cannot open.

`deadline.v1` is the same ledger without the hashlock. It exists to show what the hashlock is for, as `plain.v1` does for auth. A deadline refund alone keeps money from being stranded, the gap open PR #291 addresses with `leased.v1`. It cannot stop a key that arrives after the refund from opening the goods: in `sealed_delivery_no_hashlock` the buyer ends with the data and its money.

The `sealed_delivery` validator checks that each seller was paid if and only if its buyer opened the listed data, and re-hashes every revealed key from the record. It counts ledger facts only when the town observed them. A fault stage is tested only when the scenario declares that fault, as privacy is only tested with `redact_fields`.

This binds payment to the listed bytes, not to their usefulness. It covers digital goods only, and the ledger sees the plaintext while checking it.

It verifies integrity, not confidentiality. The evidence bundle records the sealed box in the `sealed_box` message body (`box_hex`) and the key in `key_revealed` (`key_hex`), so anyone holding `events.jsonl` can decrypt every paid order. Keeping the ciphertext out of exported records is a separate redesign.

A scenario is a short YAML file: agents and roles, the plugin per layer, the faults, the seed. Point `nandatown run path/to/your.yaml` at your own; `plugin_files:` in the YAML loads your own plugin and validator modules first.

## Track profiles
Expand Down
147 changes: 147 additions & 0 deletions src/nandatown/layers/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@

from __future__ import annotations

import hashlib
from typing import Any

from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

from . import register


Expand Down Expand Up @@ -91,3 +95,146 @@ def refund(self, ref: str) -> None:
self.balances[h["from"]] += h["cents"]
self.engine.emit("town", "escrow_refunded", ref,
{"to": h["from"], "cents": h["cents"]})


@register("payments", "deadline.v1")
class DeadlineEscrow(Ledger):
"""Deadline refund without the hashlock: the time half of hashlock.v1.

Exists to show what hashlock.v1 is for, as plain.v1 does for auth.
A hold still held at its deadline is refunded to its payer, and a
release after that refund is refused. That keeps money from being
stranded, the gap open PR #291 addresses with leased.v1, but it
cannot stop goods that arrive late from being used after the refund:
the payer ends with the goods and the money. hashlock.v1 closes
that. If #291 lands first, hashlock.v1 should build on leased.v1.
"""

TIMEOUT = 5.0

def __init__(self, engine):
super().__init__(engine)
self.expired: set[str] = set()

def hold(self, frm: str, cents: int, ref: str) -> None:
super().hold(frm, cents, ref)
expires_at = self.engine.now + self.TIMEOUT
self.escrow[ref]["expires_at"] = expires_at
self.engine.emit("town", "escrow_deadline_set", ref,
{"expires_at": expires_at,
"timeout": self.TIMEOUT})
self.engine.schedule(self.TIMEOUT, lambda: self._expire(ref))

def _expire(self, ref: str) -> None:
h = self.escrow.get(ref)
if h is None or h["state"] != "held":
return
self.expired.add(ref)
self.engine.emit("town", "escrow_expired", ref,
{"expires_at": h["expires_at"]})
self.refund(ref)

def release(self, ref: str, to: str) -> None:
if ref in self.expired:
self.engine.emit("town", "escrow_release_refused", ref,
{"to": to, "reason": "deadline passed"})
return
super().release(ref, to)


# -- sealed goods ---------------------------------------------------------


def digest(data: bytes) -> str:
return "sha256:" + hashlib.sha256(data).hexdigest()


def seal(key: bytes, nonce: bytes, plaintext: bytes) -> bytes:
"""AES-GCM: the ciphertext carries a tag, so tampering is detected."""
return AESGCM(key).encrypt(nonce, plaintext, None)


def unseal(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes | None:
try:
return AESGCM(key).decrypt(nonce, ciphertext, None)
except (InvalidTag, ValueError):
return None


@register("payments", "hashlock.v1")
class HashlockLedger(DeadlineEscrow):
"""Atomic exchange: the payee is paid only by revealing the goods' key.

The payer holds sealed goods before paying, and locks the escrow to
the payee, the key's digest and the listed content digest. The payee
claims by presenting the key. The ledger pays only if the claim is
before the deadline, the key matches its digest, the box decrypts
(AES-GCM rejects tampering) and the plaintext matches the listing;
the same step reveals the key to the payer. Otherwise the deadline
refunds the payer, who keeps a box it cannot open. Payment and the
ability to use the goods happen together or not at all.

This binds payment to the exact listed bytes; it does not judge
whether those bytes were worth buying. The ledger sees the plaintext
while checking it.

It verifies integrity, not confidentiality. The evidence bundle
records the sealed box in the sealed_box message body (box_hex) and
the key in key_revealed (key_hex), so anyone holding events.jsonl
can decrypt every paid order.
"""

def __init__(self, engine):
super().__init__(engine)
self.locks: dict[str, dict[str, Any]] = {}
self.revealed: dict[str, bytes] = {}

def hold_locked(self, frm: str, to: str, cents: int, ref: str,
key_digest: str, content_digest: str,
ciphertext: bytes, nonce: bytes) -> None:
self.hold(frm, cents, ref)
self.locks[ref] = {"payee": to, "key_digest": key_digest,
"content_digest": content_digest,
"ciphertext": ciphertext, "nonce": nonce}
self.engine.emit("town", "escrow_hashlocked", ref,
{"payee": to, "key_digest": key_digest,
"content_digest": content_digest,
"box_digest": digest(ciphertext)})

def claim(self, ref: str, claimant: str, key: bytes) -> bool:
lock, held = self.locks.get(ref), self.escrow.get(ref)
if lock is None or held is None:
raise PaymentError(f"escrow ref {ref} is not hashlocked")
reason = None
if claimant != lock["payee"]:
reason = "claimant is not the payee"
elif not isinstance(key, bytes):
reason = "key is not bytes"
elif held["state"] != "held":
reason = ("deadline passed" if ref in self.expired
else f"escrow {held['state']}")
elif digest(key) != lock["key_digest"]:
reason = "key does not match its digest"
else:
plaintext = unseal(key, lock["nonce"], lock["ciphertext"])
if plaintext is None:
reason = "box does not decrypt"
elif digest(plaintext) != lock["content_digest"]:
reason = "content does not match the listing"
if reason:
self.engine.emit("town", "claim_refused", ref,
{"claimant": claimant, "reason": reason})
return False
super().release(ref, lock["payee"])
self.revealed[ref] = key
self.engine.emit("town", "key_revealed", ref,
{"to": held["from"], "key_hex": key.hex()})
return True

def release(self, ref: str, to: str) -> None:
if ref in self.locks:
self.engine.emit("town", "escrow_release_refused", ref,
{"to": to, "reason": "hashlocked: only a key"
" claim pays"})
return
super().release(ref, to)
7 changes: 7 additions & 0 deletions src/nandatown/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@
" refused",
"listing_intact": "a quote request reached the honest seller after"
" the forged card",
"escrow_resolved": "every escrow ended with exactly one party",
"paid_only_for_verified_box": "no payment before the sealed box checked"
" out",
"damaged_box_refused": "a box failing its digest was never paid for",
"wrong_content_refused": "a key opening the wrong data earned nothing",
"late_claim_refused": "a key claim after the deadline earned nothing",
"atomic_exchange": "paid if and only if the buyer opened the goods",
"ledger_conserved": "money was conserved across every movement",
"privacy": "declared private fields never left the run",
"resolution": "the subject was found through the declared path",
Expand Down
Loading