Skip to content

[Fellowship Challenge] payments: hashlock.v1, sealed digital goods and payment change hands in one step - #298

Open
saisreekantam wants to merge 6 commits into
projnanda:mainfrom
saisreekantam:challenge/saisreekantam-sealed-exchange
Open

saisreekantam wants to merge 6 commits into
projnanda:mainfrom
saisreekantam:challenge/saisreekantam-sealed-exchange

Conversation

@saisreekantam

@saisreekantam saisreekantam commented Oct 1, 2026 •

Copy link
Copy Markdown

Submission for the Hexaware x Project NANDA Fellowship Technical Challenge

Demo video: https://www.youtube.com/watch?v=QVAOcTkux3Y

Track: Lab · Layer: payments. The PR adds hashlock.v1, and deadline.v1 as its time half and negative control. ledger.v1 and every existing scenario are unchanged; their event traces are identical to main.

The gap

Escrow in the Lab can hold the money, but nothing ties the money to the goods. Open PR #291 (leased.v1) fixes one half of that: a hold that is never released is refunded at a deadline, so money is not stranded.

A deadline refund opens the other half. If the goods arrive after the refund, the buyer keeps the goods and the money. Payment and delivery are still separate events, and a delay can split them. #291 names this as its next step: "so a buyer cannot keep both the goods and the money."

For digital goods, it can be closed completely: make the seller's payment and the buyer's ability to use the goods the same event.

The change: hashlock.v1 (payments)

  • Listing. The seller's registry card lists content_digest = sha256(data).

  • Sealed box. The seller sends the data sealed with AES-GCM under a fresh key, with box_digest and key_digest.

  • Pay only with the box in hand. The buyer checks the box against its digest and the listing. hold_locked then binds the escrow to the payee, the key digest and the content digest.

  • Claim. The seller is paid only through claim(ref, key). The ledger checks, in order:

    1. the claimant is the payee,
    2. the claim is before the deadline,
    3. sha256(key) matches the key digest,
    4. the box decrypts (the AES-GCM tag rejects tampering),
    5. the plaintext matches the listing.

    If every check passes, it pays the seller and reveals the key to the buyer in the same step. Otherwise it emits claim_refused {reason} and reveals nothing, and the deadline refunds the buyer, who keeps a box it cannot open.

  • No bypass. Plain release cannot bypass the key.

  • Cancelling. The buyer may still refund before the seller claims, for instance while the claim is in transit. It gets its money back but never the key, so it cannot end up with both.

deadline.v1 is the same ledger without the hashlock. It is included as the negative control, as plain.v1 is for auth. It is the same mechanism as #291's leased.v1. If #291 lands first, hashlock.v1 should subclass leased.v1 and deadline.v1 goes away.

Other code.

  • TownAPI gains hashlock_hold, hashlock_claim, revealed_key, escrow_state and supports_hashlock. Intents record key digests, never keys.
  • Two Lab roles, data-seller and data-buyer, speak the same protocol under either ledger. Under a ledger without the hashlock, the seller sends the key itself. That keeps the control fair: same agents, only the rules differ.
  • Both plugins are drop-ins for every bundled scenario, with identical stage verdicts (tested).

Fault and invariant: sealed_delivery

Four buyers each buy a dataset from a different seller. Staggered starts put one fault on each pair:

Pair Fault Outcome
buyer-1 / seller-a the escrow_locked notice is delayed 10 s, so the key claim arrives after the 5.0 s deadline claim_refused: deadline passed; refunded; box stays sealed
buyer-2 / seller-b the sealed box is dropped in transit nothing paid; re-requested; paid and opened
buyer-3 / seller-c the first box is damaged after sealing box_rejected: box digest mismatch before payment; resent; paid and opened
buyer-4 / seller-d the seller seals other bytes under its listing (the box itself is consistent) claim_refused: content does not match the listing; refunded; key withheld

Invariant: each seller is paid if and only if its buyer opened the listed data.

The sealed_delivery validator judges only events.jsonl:

  • It re-hashes every revealed key against its locked digest, so verify re-checks the cryptographic binding offline.
  • It counts ledger facts only when the town observed them, since api.observe lets an agent record any event kind.
  • It counts goods_opened only from the paying buyer.
  • A fault stage is tested only when the scenario declares that fault, as privacy is only tested with redact_fields. If the fault is declared but leaves no evidence, the stage is inconclusive, never passed.

Negative control: sealed_delivery_no_hashlock

This is the same file with payments: deadline.v1. A test pins that nothing else differs. Predicted before running, and observed:

Stage sealed_delivery (hashlock.v1) control (deadline.v1)
escrow_resolved passed passed
paid_only_for_verified_box passed passed (the buyer's own check)
damaged_box_refused passed passed (the buyer's own check)
wrong_content_refused passed not_enough_evidence
late_claim_refused passed not_enough_evidence
atomic_exchange passed failed: order-buyer-1-1 goods opened without payment
honest_trade_completed passed passed
ledger_conserved passed passed
verdict passed failed

In the control, buyer-1 is refunded at 5.7, the late key reaches it at 10.9, and it opens data it no longer pays for. nandatown compare sealed_delivery --swap payments=deadline.v1 reports exactly the three differing stages.

Bundle fingerprints

Both pass nandatown verify ("hashes match and the evaluator reproduces the recorded result"):

  • sealed_delivery: sha256:8aeb9ab17b0051e0b14742bce337082c83a6e08ccef6156a4162d4595e0927c1
  • sealed_delivery_no_hashlock: sha256:1e9aa8ca931bf2c38ee7bbfada93901567fdcacbc0b2d4cb92b6fcce52e670a4

The Lab run id is random, so a rerun gives a new fingerprint with the same stage results.

nandatown run sealed_delivery
nandatown run sealed_delivery_no_hashlock
nandatown compare sealed_delivery --swap payments=deadline.v1
nandatown verify runs/<id>

Optional extra, moved to #313

At review, the model-driven buyers (examples/llm_market/, with its test and MANIFEST line) moved into their own pull request, #313, which is stacked on this one. They are unchanged.

Follow-up

The confidentiality redesign (optimistic, FairSwap-style verification) is tracked in #312, with its three invariants and its test cases. It stays out of this PR.

Tests

tests/test_sealed_delivery.py (40) fails on main and passes here. It covers:

  • the deadline half: refund at the deadline, a no-op timer after release, a refused late release, unchanged ledger errors;
  • every claim refusal: wrong key, wrong payee, a box that won't decrypt, content mismatch, late claim, plain release, a non-bytes key;
  • a buyer refund before the claim: money back, key never revealed, so no goods and no second refund;
  • AES-GCM tamper detection;
  • the scenario and its control, both verified, and the control differing only in payments;
  • drop-in use in all six bundled scenarios;
  • regressions from my own review:
    • a delayed earlier box after a retry must still trade (the seller keeps keys by digest);
    • an under-funded buyer gives up instead of crashing the run;
  • the validator rejecting hand-written traces: a key that does not hash to its digest, payment before a verified box, payment without the goods opening, and ledger events or goods_opened asserted by the wrong party;
  • the declared-fault rule;
  • escrow_state visible only to the escrow's parties;
  • every stage verdict unchanged when the deadline event is relabelled as [Fellowship Challenge] payments: leased escrow, a stranded hold refunds itself when its lease ends #291's escrow_leased, so building on leased.v1 would not change the judgment.

Full suite, rebased on main after #290, #292, #300 and #303: 1906 passed on Python 3.11 and 3.12. hashlock.v1 is a drop-in for all eight bundled scenarios, with unchanged verdicts. python -m build and scripts/check_distributions.py pass, and the installed wheel runs both scenarios outside the checkout.

Evaluator version

No existing validator rule changes, and existing scenarios produce identical traces, so the Lab evaluator stays lab-0.2.6. Bumping it would make every existing lab-0.2.6 bundle fail replay for no change in meaning. An older build without sealed_delivery would report "no validator registered" for these bundles, which is a mismatch, not a false pass.

What this does not prove

  • It covers digital goods only. A physical delivery cannot be sealed; for those, a deadline refund still leaves the late-goods dispute open.
  • It binds payment to the listed bytes, not to their usefulness. A seller who lists junk is paid for junk. The listing digest is only as trustworthy as the seller's signed card.
  • 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 the redesign tracked in hashlock.v1: keep sealed goods confidential in evidence bundles (optimistic, FairSwap-style verification) #312.
  • The ledger sees the plaintext while checking it. A zero-knowledge contingent payment would let it check without seeing the data. That is the next step, not this PR.
  • Keys come from the run's seeded generator, so the Lab stays deterministic. They are not secret from anyone who knows the seed. This is a protocol test, not key management.
  • goods_opened is the buyer's attributed assertion. In the Lab the buyer is reference code; an external buyer's report would be a claim, not independent observation.
  • The deadline is a fixed 5.0 logical seconds. Scenarios cannot configure plugin parameters today.
  • It is Lab only, and each result is a single seeded run, not a reliability claim.

@Redeem-Grimm-Satoshi

Copy link
Copy Markdown
Collaborator

Hi @saisreekantam,

Built this fresh against main and reproduced everything in the description: both scenarios, the compare, the scripted LLM market, and all bundles verifying. The buyer-1 timeline (hold 0.70, refund 5.70, late claim refused 10.80) matches your numbers exactly, and the control fails on atomic_exchange for the reason you predicted.

I also disabled each ledger check and the validator's key rehash one at a time. Every one tripped a specific test. Swapping hashlock.v1 into the six existing scenarios changes no verdicts.

Watched the demo as well. What's on screen lines up with what's in the bundle, and you were straight about what the run does and doesn't prove. That matters more to us than polish.

One blocker: the branch now conflicts with main in four files after #290 and #303 merged. All are adjacent line additions, so the rebase should be quick. Please push it and confirm the suite is green.

Minor, non-blocking:

  • escrow_state is readable by any agent for any ref, unlike revealed_key. Guard it or note why it's public.
  • _escrow_index builds a deadlines map nothing uses.
  • On [Fellowship Challenge] payments: leased escrow, a stranded hold refunds itself when its lease ends #291: agreed on adapting if it lands first. Since you're rebasing anyway, a one-line test showing the validator still reports the same three differing stages if the deadline event kind is renamed would tell us the subclassing plan is a ten-minute job.
  • The examples/llm_market extra is good but doubles the review surface. A follow-up PR would have been easier.

One question: the ledger decrypts the box to check the digest, so the town sees the plaintext. Is there a step short of ZK that avoids that while keeping the binding re-checkable offline from events.jsonl?

Sreekantam sai venkat added 4 commits October 19, 2026 01:17
A deadline refund keeps escrow from being stranded (open PR projnanda#291), but
goods that arrive after the refund can still be used: the buyer keeps
the goods and the money. hashlock.v1 makes payment and delivery one
step.

The seller lists a digest of the data and sends it sealed with AES-GCM
under a fresh key. The buyer pays only while holding a box that
matches its digest and the listing, and locks the escrow to the payee,
the key digest and the content digest. The seller is paid only by
handing the ledger the key. The ledger checks the payee, the deadline,
the key digest, that the box decrypts and that the plaintext matches
the listing, and reveals the key to the buyer in the same step.
Otherwise the deadline refunds the buyer, who keeps a box it cannot
open. Plain release cannot bypass the key.

deadline.v1 is the same ledger without the hashlock: the time half,
and the negative control, as plain.v1 is for auth.

sealed_delivery injects a lost box, a damaged box, a seller sealing the
wrong content and a key claim delayed past the deadline. Its validator
checks that nothing is paid before a verified box, damaged boxes and
bad claims earn nothing, and each seller is paid if and only if its
buyer opened the goods, re-hashing 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.
sealed_delivery_no_hashlock runs the same agents on deadline.v1 and
fails atomic_exchange: a late key opens goods whose payment was
refunded.

TownAPI gains hashlock_hold, hashlock_claim, revealed_key, escrow_state
and supports_hashlock; intents record key digests, never keys. Both
plugins are drop-ins for every bundled scenario with identical stage
verdicts, and existing scenario traces are identical to main.
The fellowship brief's optional extra: bring your own agents into a
scenario you design. Two llm-data-buyer agents ask a model, by tool
call, which of three sellers to buy a dataset from (seeing price and
town reputation) and what to do after an order fails. The cheapest
seller seals the wrong data and one key claim arrives late. The
scenario loads the buyers with plugin_files and uses the bundled
sealed_delivery validator, so verify replays it.

Code checks every model call (a tool offered at that point, a listed
seller, a price within the cap) and records each decision as a
model_decision event. The sealed-box protocol itself is never left to
the model.

llm_market runs on hashlock.v1; llm_market_no_hashlock is the same
file on deadline.v1 and fails atomic_exchange. Tests use the
scripted:v1 brain, so CI needs no model. MANIFEST ships the example's
YAML and README in the sdist.
Five runs of each scenario on a local Ollama: llm_market passed 5/5,
llm_market_no_hashlock failed 5/5 on atomic_exchange, all ten bundles
verify, and the guardrail rejected no calls. The model bought from a
seller with negative reputation because it was cheapest; the hashlock
refunded it.
The buyer may cancel a hashlocked escrow before the seller claims, for
instance while the claim is in transit. It gets its money back but the
key is never revealed, so the box it holds stays sealed; the seller's
later claim is refused as "escrow refunded" and the deadline refunds
nothing a second time. Neither side can end up with both.
@saisreekantam
saisreekantam force-pushed the challenge/saisreekantam-sealed-exchange branch from 603ba1e to 3f10183 Compare October 18, 2026 19:55
…e event

- escrow_state now answers only the escrow's parties: its payer, or the
  payee a hashlocked escrow names. Other agents get None, as with
  revealed_key. Every caller was already a buyer reading its own order.
- _escrow_index no longer builds a deadlines map; nothing read it once
  the escrow_deadline validator was removed. _is_time goes with it.
- New test: relabelling each escrow_deadline_set as projnanda#291's escrow_leased
  {from, cents, expires_at} leaves every stage verdict of both runs
  unchanged, and the same three stages still separate them. The
  validator never reads the deadline event, so building hashlock.v1 on
  leased.v1 would not change the judgment.
@saisreekantam

Copy link
Copy Markdown
Author

Thanks for reproducing all of this and for the careful review.

Rebase. I've rebased onto main (0931790, after #290, #292, #300, and #303). The four conflicts were adjacent additions, and I preserved both sides. One issue surfaced in validators.py: Git treated the trailing return stages as shared between registry_eviction and sealed_delivery, leaving #303's validator returning None. I've restored it and checked that this branch only adds lines to the shared files.

The full suite passes: 1,915 tests on Python 3.11 and 3.12. hashlock.v1 remains a drop-in for all eight bundled scenarios, including capped_influence and registry_eviction, with unchanged verdicts. The traces for all eight scenarios are identical to main.

Non-blocking items (b60d4e5):

  • escrow_state now returns state only to the escrow's parties: the payer or, for a hashlocked escrow, the named payee. Other agents receive None, consistent with revealed_key. Existing callers read their own orders.
  • Removed the unused deadlines map from _escrow_index, along with _is_time.
  • [Fellowship Challenge] payments: leased escrow, a stranded hold refunds itself when its lease ends #291: Added a test that relabels every escrow_deadline_set event as escrow_leased {from, cents, expires_at}. Both runs retain every stage verdict, and the same three stages still distinguish the runs. The validator doesn't inspect the deadline event, so moving to leased.v1 should not change the judgment.
  • examples/llm_market: Agreed that this would be easier to review separately. I'm happy to move it, its test, and the MANIFEST entry into a follow-up PR.

Plaintext and confidentiality. One limitation I should own: the exposure extends beyond the ledger. The sealed box is recorded in message bodies, and key_revealed records the decryption key. So anyone with the evidence bundle can decrypt the affected digital goods. I confirmed this on one of the PR's bundles. The current protocol verifies content integrity but does not preserve confidentiality against evidence-bundle readers.

A possible follow-up is optimistic verification in the style of FairSwap (Dziembowski, Eckey, and Faust, CCS 2018):

  • Commitments: Encrypt the data in chunks and commit to Merkle roots for the plaintext and ciphertext chunks before the buyer accepts the listing.
  • Claim: Verify the key against key_digest, reveal it to the buyer, and hold the payment in a dispute window, without decrypting the file during an honest transaction.
  • Complaint: Let the buyer submit a disputed ciphertext chunk and its Merkle proof, together with the committed plaintext digest and its proof. Verify the relevant chunk and determine the dispute outcome.
  • Evidence: Keep the ciphertext out of exported event records, retaining its digest or Merkle root instead. Record only the disputed chunk and the evidence needed to verify a complaint. The box itself must be delivered separately to the buyer.

This would reduce routine plaintext exposure, but it would not by itself guarantee end-to-end confidentiality. Key visibility, ciphertext access, and the dispute protocol must be considered together. Offline verification can still check the key binding from the recorded key and digest; checking the ciphertext contents requires the ciphertext or appropriate dispute evidence.

There is also an important distinction between integrity and correctness. A seller's plaintext root, committed before the buyer accepts, can establish consistency with the advertised data. It cannot establish that the data is what the buyer actually wanted. That stronger guarantee requires an independent reference, such as a buyer-approved digest, a publisher-signed data_facts record, or a verifiable predicate. The same limitation applies to the current listing digest.

I'd keep hashlock.v1 unchanged in this PR and handle the redesign separately, with three explicit invariants:

  1. Commitment: Both roots are fixed before payment.
  2. Dispute soundness: Inconsistent chunks can be challenged with deterministic outcomes.
  3. Confidentiality: Honest transactions do not expose the full plaintext or leave exported evidence sufficient to decrypt the entire good.

Tests would cover honest delivery, corrupted ciphertext, false complaints, malformed Merkle proofs, and a seller who commits to incorrect data from the outset. In the last case, the protocol must distinguish consistency with the commitment from correctness of the underlying data.

Thanks again for the thorough review.

@Redeem-Grimm-Satoshi

Copy link
Copy Markdown
Collaborator

Thanks for reproducing all of this and for the careful review.

Rebase. I've rebased onto main (0931790, after #290, #292, #300, and #303). The four conflicts were adjacent additions, and I preserved both sides. One issue surfaced in validators.py: Git treated the trailing return stages as shared between registry_eviction and sealed_delivery, leaving #303's validator returning None. I've restored it and checked that this branch only adds lines to the shared files.

The full suite passes: 1,915 tests on Python 3.11 and 3.12. hashlock.v1 remains a drop-in for all eight bundled scenarios, including capped_influence and registry_eviction, with unchanged verdicts. The traces for all eight scenarios are identical to main.

Non-blocking items (b60d4e5):

  • escrow_state now returns state only to the escrow's parties: the payer or, for a hashlocked escrow, the named payee. Other agents receive None, consistent with revealed_key. Existing callers read their own orders.
  • Removed the unused deadlines map from _escrow_index, along with _is_time.
  • [Fellowship Challenge] payments: leased escrow, a stranded hold refunds itself when its lease ends #291: Added a test that relabels every escrow_deadline_set event as escrow_leased {from, cents, expires_at}. Both runs retain every stage verdict, and the same three stages still distinguish the runs. The validator doesn't inspect the deadline event, so moving to leased.v1 should not change the judgment.
  • examples/llm_market: Agreed that this would be easier to review separately. I'm happy to move it, its test, and the MANIFEST entry into a follow-up PR.

Plaintext and confidentiality. One limitation I should own: the exposure extends beyond the ledger. The sealed box is recorded in message bodies, and key_revealed records the decryption key. So anyone with the evidence bundle can decrypt the affected digital goods. I confirmed this on one of the PR's bundles. The current protocol verifies content integrity but does not preserve confidentiality against evidence-bundle readers.

A possible follow-up is optimistic verification in the style of FairSwap (Dziembowski, Eckey, and Faust, CCS 2018):

  • Commitments: Encrypt the data in chunks and commit to Merkle roots for the plaintext and ciphertext chunks before the buyer accepts the listing.
  • Claim: Verify the key against key_digest, reveal it to the buyer, and hold the payment in a dispute window, without decrypting the file during an honest transaction.
  • Complaint: Let the buyer submit a disputed ciphertext chunk and its Merkle proof, together with the committed plaintext digest and its proof. Verify the relevant chunk and determine the dispute outcome.
  • Evidence: Keep the ciphertext out of exported event records, retaining its digest or Merkle root instead. Record only the disputed chunk and the evidence needed to verify a complaint. The box itself must be delivered separately to the buyer.

This would reduce routine plaintext exposure, but it would not by itself guarantee end-to-end confidentiality. Key visibility, ciphertext access, and the dispute protocol must be considered together. Offline verification can still check the key binding from the recorded key and digest; checking the ciphertext contents requires the ciphertext or appropriate dispute evidence.

There is also an important distinction between integrity and correctness. A seller's plaintext root, committed before the buyer accepts, can establish consistency with the advertised data. It cannot establish that the data is what the buyer actually wanted. That stronger guarantee requires an independent reference, such as a buyer-approved digest, a publisher-signed data_facts record, or a verifiable predicate. The same limitation applies to the current listing digest.

I'd keep hashlock.v1 unchanged in this PR and handle the redesign separately, with three explicit invariants:

  1. Commitment: Both roots are fixed before payment.
  2. Dispute soundness: Inconsistent chunks can be challenged with deterministic outcomes.
  3. Confidentiality: Honest transactions do not expose the full plaintext or leave exported evidence sufficient to decrypt the entire good.

Tests would cover honest delivery, corrupted ciphertext, false complaints, malformed Merkle proofs, and a seller who commits to incorrect data from the outset. In the last case, the protocol must distinguish consistency with the commitment from correctness of the underlying data.

Thanks again for the thorough review.

Awesome!

Rebase: looks good to me, and the registry_eviction return is restored. Both scenarios run and verify, and the full suite here matches main with nothing new failing.

Follow-ups: All three done, no notes. The escrow_leased relabel test settles the #291 question; subclassing later is a straight refactor.

examples/llm_market: Please move it to its own PR with the test and the MANIFEST line.

Plaintext: Confirmed. From the original bundle alone, box_hex in message_sent plus key_hex in key_revealed recovers both paid-for datasets. That is a real limitation for a sealed-goods plugin and was missing from the original list. Good that you found it, and the FairSwap direction is sensible.

Before merge:

  1. Add the bundle decryption limitation to the README and the HashlockLedger docstring.
  2. Keep the redesign out of this PR and open an issue with the three invariants you listed.

After that and the example split, I'm done here. Thanks for the quick turnaround. :)

…et out

- README and the HashlockLedger docstring now say hashlock.v1 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.
- examples/llm_market, its test and its MANIFEST line move to their own
  pull request, as requested in review. MANIFEST.in matches main again.
@saisreekantam

Copy link
Copy Markdown
Author

Done:

The suite passes on 3.11 and 3.12 (1906 here, 1915 on #313). Thanks again!

@saisreekantam

Copy link
Copy Markdown
Author

Hi @Redeem-Grimm-Satoshi, hope you're well. I wanted to check on next steps for this PR when you have a moment.

The items from your last review are in: the confidentiality limit is documented in the README and the docstring, the example has moved to #313, and the redesign is tracked in #312. The branch is still mergeable against main.

I'd also be grateful for any update you can share on the next steps of the Hexaware x Project NANDA Fellowship process, or a pointer to the right channel if this isn't the place to ask.

If anything else is needed from my side, I'm happy to take care of it. Thank you for your time and the thoughtful review.

This branch has not been deployed

No deployments
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.

2 participants