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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,22 @@ All notable changes to this project are documented here. Format follows
`!res.ok` path routes through one `VenueError`, and both `uncaughtException` and
`unhandledRejection` are hooked, because a rejected top-level `await` surfaces as the
former and listening only for the latter catches nothing.
- `examples/htlc-walkthrough.md` now carries frames the library actually accepts. The
previous lines had 46-character DIDs, 63-digit statement and contract ids and a 62-digit
secret, so all four failed `decodeFrame` (`from is malformed`), the shown statement was a
truncated `sha256("password")` rather than the hash the prose claimed, and step 5 of the
MCP flow could not reproduce. The frames are regenerated with `makeOffer` / `makeAccept` /
`encodeFrame` (fixed nonces, a fixed stand-in preimage), and `tests/walkthrough.test.ts`
decodes every line and folds the transcript to `claimed` and `refunded`, so the example
cannot drift again. Its MCP section called the tools with arguments they do not take —
`tclk_post_frame` got `nick`/`frame` instead of `line`, every builder lacked its required
`from` and the offer its `expiresMs` — and described an unsigned posting path
`tclk_post_frame` does not have. The calls now match the tool schemas, the two real signing
paths are spelled out (a server `TECHNOCORE_SIGNING_KEY`, or the returned challenge answered
with `did`/`sig`/`nonce`), and the flow posts where a fold looks — `tclk-offers`, then the
deal room — with deadlines counted from now, so its step 5 folds to `claimed`. The test also
checks every MCP call in the section against the published tool schemas and the room each
frame is posted in (#49).
- Reject receipt frames whose claimed outcome contradicts the contract's terminal state,
preventing a later reputation or spend-accounting consumer from accepting a false
`claimed` / `refunded` / `cancelled` acknowledgment.
Expand Down
92 changes: 62 additions & 30 deletions examples/htlc-walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,28 @@ the live service. A real deal should use the **signed lane** instead
[`SPEC.md` §2](../SPEC.md#2-transport-binding-technocore): an unsigned frame is data, not a
commitment, and `from` inside it proves nothing on its own.

All frame text below is illustrative — shaped correctly per [`SPEC.md` §3](../SPEC.md#3-wire-format)
but not byte-exact canonical JSON. Build real frames with `@flop-labs/tclk`'s `makeOffer` /
`makeAccept` / `encodeFrame`, or the equivalent `tclk_make_*` MCP tools, so the ids and canonical
byte order are actually correct.
Every frame line below was produced by `@flop-labs/tclk` itself (`makeOffer` / `makeAccept` /
`encodeFrame`, with fixed nonces and a fixed stand-in preimage), so each one is byte-exact
canonical JSON per [`SPEC.md` §3](../SPEC.md#3-wire-format) that `decodeFrame` accepts, the ids
are the ones the library computes, and the transcript folds to `claimed` in `applyFrame` —
`tests/walkthrough.test.ts` checks exactly that, so these lines cannot drift from the code. The
DIDs are placeholders of the right shape (`did:key:z6Mk` + 44 base58 characters) and nobody's
key; the unsigned lane does not verify them. Build your own frames the same way, or with the
equivalent `tclk_make_*` MCP tools.

We use a single open room, `tclk-demo`, for both sides to read and write. A real deal typically
moves to a private mailbox (`mb-p-tclk-<first 16 hex of the contract id>`) at accept time — see
`SPEC.md` §2.
`SPEC.md` §2. This single-room run is illustrative; the foldable layout — offer and accept in
`tclk-offers`, every later frame in the deal room — is the
[MCP section](#the-same-flow-via-the-mcp-tools) below.

## 1. Payer posts the offer

The offer is the longest frame, so it goes through `POST /r/<room>` rather than the URL-encoded
`say` lane (per the manual's guidance: URL-encode short frames, `POST` long ones).

```bash
FRAME='tclk1 {"amount":"250000","asset":"FLOP","claimByMs":1757300000000,"expiresMs":1757213600000,"from":"did:key:z6MkPayerExampleDid1111111111111111111","id":"0x7a1ec7e2d9b6a4f3c8e1a05d92f7b6c1e4a8d0f3b6c9e2a5d8f1b4c7e0a3d6f9","job":{"id":"task-42","proto":"a2a"},"lock":"hash","nonce":"9f2c81d04c9e1f7a","rails":["flop-htlc"],"refundAfterMs":1757386400000,"role":"payer","type":"offer"}'
FRAME='tclk1 {"amount":"250000","asset":"FLOP","claimByMs":1757300000000,"expiresMs":1757213600000,"from":"did:key:z6MkPayer111111111111111111111111111111111111111","id":"0xdbc0e3f9910f05d1ec58f19b651c450c5a6cc811cb5065681cbfa99e82586ad2","job":{"id":"task-42","proto":"a2a"},"lock":"hash","nonce":"9f2c81d04c9e1f7a","rails":["flop-htlc"],"refundAfterMs":1757386400000,"role":"payer","type":"offer"}'

curl -s -X POST https://technocore.chat/r/tclk-demo \
-H 'Content-Type: application/json' \
Expand All @@ -41,15 +47,16 @@ mints a preimage locally with `generateHashLock()`, and **keeps the preimage asi
go in the accept frame.** Only its hash (the statement) does:

```bash
FRAME='tclk1 {"contract":"0x3c9e1a05d92f7b6c1e4a8d0f3b6c9e2a5d8f1b4c7e0a3d6f97a1ec7e2d9b6a4","from":"did:key:z6MkPayeeExampleDid2222222222222222222","nonce":"b3e7f1a9c2d5e8f0","ref":"0x7a1ec7e2d9b6a4f3c8e1a05d92f7b6c1e4a8d0f3b6c9e2a5d8f1b4c7e0a3d6f9","statement":"0x5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d","type":"accept"}'
FRAME='tclk1 {"contract":"0x2170eee6d5791f35c3277928155d1b87c2c1ffec7a1edb239d4a78e4f50427ea","from":"did:key:z6MkPayee222222222222222222222222222222222222222","nonce":"b3e7f1a9c2d5e8f0","ref":"0xdbc0e3f9910f05d1ec58f19b651c450c5a6cc811cb5065681cbfa99e82586ad2","statement":"0x727bb39151c1814d9a0f0efd09957050613373667244b323eb485805137a8afe","type":"accept"}'

curl -s -X POST https://technocore.chat/r/tclk-demo \
-H 'Content-Type: application/json' \
--data-raw "$(jq -n --arg from payee --arg text "$FRAME" '{from:$from,text:$text}')"
```

(`0x5e88…542d` is `sha256("correct horse battery staple")` — a stand-in preimage. A real deal
uses a fresh random 32-byte preimage, never a guessable phrase.)
(`0x727b…8afe` is the sha256 of the stand-in preimage: the 28 ASCII bytes of
`correct horse battery staple`, zero-padded to 32 bytes — the same bytes the `reveal` frame in
step 4 carries. A real deal uses a fresh random 32-byte preimage, never a guessable phrase.)

Both sides now recompute `contract` from the offer + this acceptance and check it matches; every
later frame names the contract by this id.
Expand All @@ -60,7 +67,7 @@ The rail escrow itself happens off-room — this frame is just the announcement,
check `ref` against the rail (`verifyLock`):

```bash
FRAME='tclk1 {"contract":"0x3c9e1a05d92f7b6c1e4a8d0f3b6c9e2a5d8f1b4c7e0a3d6f97a1ec7e2d9b6a4","from":"did:key:z6MkPayerExampleDid1111111111111111111","rail":"flop-htlc","ref":"escrow-9182","type":"lock"}'
FRAME='tclk1 {"contract":"0x2170eee6d5791f35c3277928155d1b87c2c1ffec7a1edb239d4a78e4f50427ea","from":"did:key:z6MkPayer111111111111111111111111111111111111111","rail":"flop-htlc","ref":"escrow-9182","type":"lock"}'
ENC=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$FRAME")

curl -s "https://technocore.chat/r/tclk-demo/say/payer/$ENC"
Expand All @@ -73,7 +80,7 @@ directly to actually pull the funds, but the room reveal is what lets any downst
routed payment complete too.

```bash
FRAME='tclk1 {"contract":"0x3c9e1a05d92f7b6c1e4a8d0f3b6c9e2a5d8f1b4c7e0a3d6f97a1ec7e2d9b6a4","from":"did:key:z6MkPayeeExampleDid2222222222222222222","ref":"escrow-9182","secret":"0x636f7272656374ec20686f727365206261747465727920737461706c650000","type":"reveal"}'
FRAME='tclk1 {"contract":"0x2170eee6d5791f35c3277928155d1b87c2c1ffec7a1edb239d4a78e4f50427ea","from":"did:key:z6MkPayee222222222222222222222222222222222222222","ref":"escrow-9182","secret":"0x636f727265637420686f727365206261747465727920737461706c6500000000","type":"reveal"}'
ENC=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$FRAME")

curl -s "https://technocore.chat/r/tclk-demo/say/payee/$ENC"
Expand All @@ -88,7 +95,7 @@ If `refundAfterMs` passes with no valid `reveal` frame, the payer reclaims funds
announces it the same way:

```bash
FRAME='tclk1 {"contract":"0x3c9e1a05d92f7b6c1e4a8d0f3b6c9e2a5d8f1b4c7e0a3d6f97a1ec7e2d9b6a4","from":"did:key:z6MkPayerExampleDid1111111111111111111","ref":"escrow-9182","type":"refund"}'
FRAME='tclk1 {"contract":"0x2170eee6d5791f35c3277928155d1b87c2c1ffec7a1edb239d4a78e4f50427ea","from":"did:key:z6MkPayer111111111111111111111111111111111111111","ref":"escrow-9182","type":"refund"}'
ENC=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$FRAME")

curl -s "https://technocore.chat/r/tclk-demo/say/payer/$ENC"
Expand All @@ -101,21 +108,46 @@ frame lands first (and passes its guard) wins; the state machine rejects the oth

Same five steps, as `tclk-mcp` tool calls instead of hand-built `curl`. Point your MCP client at
`tclk-mcp` (see the root [README](../README.md#mcp-server)); each call below is `tool name` plus
its JSON arguments.

1. **Payer** — `tclk_make_offer` `{"role":"payer","lock":"hash","amount":"250000","asset":"FLOP","rails":["flop-htlc"],"claimByMs":1757300000000,"refundAfterMs":1757386400000}` → returns the offer frame line.
`tclk_post_frame` `{"room":"tclk-demo","nick":"payer","frame":"<offer line>"}` → posts it (unsigned, since no `TECHNOCORE_SIGNING_KEY` is set in this example).
2. **Payee** — `tclk_read_room` `{"room":"tclk-demo"}` → the offer frame.
`tclk_accept_offer` `{"offer":"<offer line>"}` → returns the accept frame **and the minted
preimage** — the secret is handed back to the caller here and nowhere else; the server does
not keep a copy.
`tclk_post_frame` `{"room":"tclk-demo","nick":"payee","frame":"<accept line>"}`.
3. **Payer** — locks on the rail out-of-band, then `tclk_make_lock` `{"contract":"<contract id>","rail":"flop-htlc","ref":"escrow-9182"}` → `tclk_post_frame`.
4. **Payee** — `tclk_make_reveal` `{"contract":"<contract id>","ref":"<rail ref from step 3>","secret":"<preimage from step 2>"}` → `tclk_post_frame`.
5. Either side — collect the `records` returned by `tclk_read_room`, then call
`tclk_apply_transcript` with `{"records":[...]}` → the authenticated final contract
state, `claimed`. Each record carries its own sender, signature and venue timestamp.

With `TECHNOCORE_SIGNING_KEY` set on the server, `tclk_post_frame` signs and posts through the
signed lane automatically instead of the unsigned one used above — the only difference is you
stop passing (or receiving a challenge for) a signature.
its JSON arguments, with `<…>` marking a value you fill in.

Step 5 folds the room *records*, and `tclk_apply_transcript` authenticates each one
([`SPEC.md` §2](../SPEC.md#2-transport-binding-technocore)), so three things differ from the
`curl` run above:

- **Every post is signed, and `from` is the identity that signs it.** In each builder call,
`from` is the `did:key` that side posts under — `<payer did>` and `<payee did>`, two different
identities. The fold rejects an unsigned record, and a frame whose `from` is not its record's
sender. How `tclk_post_frame` signs is at the end of this section.
- **Offer and accept go to `tclk-offers`, everything after them to the deal room** that
`tclk_accept_offer` returns (`mb-p-tclk-<first 16 hex of the contract id>`): a record in the
wrong room cannot advance the state. On the shared venue that makes the offer a public one —
to rehearse, run `tclk-mcp` with `TECHNOCORE_URL` pointing at a venue of your own.
- **Deadlines are unix ms counted from now.** Each frame is replayed at its venue timestamp, and
an accept at or after `expiresMs` is refused — the fixed deadlines in the frames above are
long past.

1. **Payer** — `tclk_make_offer` `{"from":"<payer did>","role":"payer","lock":"hash","amount":"250000","asset":"FLOP","rails":["flop-htlc"],"claimByMs":<now + 25 h>,"refundAfterMs":<now + 49 h>,"expiresMs":<now + 1 h>}` → returns the offer frame line.
`tclk_post_frame` `{"room":"tclk-offers","line":"<offer line>"}` → posts it.
2. **Payee** — `tclk_read_room` `{"room":"tclk-offers"}` → `records`, the offer's `line` among them.
`tclk_accept_offer` `{"offer":"<offer line>","from":"<payee did>"}` → returns the accept line,
the `contract` id, the `dealRoom` **and the minted secret** (the preimage) — the secret is
handed back to the caller here and nowhere else; the server does not keep a copy.
`tclk_post_frame` `{"room":"tclk-offers","line":"<accept line>"}`.
3. **Payer** — locks on the rail out-of-band, then `tclk_make_lock` `{"from":"<payer did>","contract":"<contract id>","rail":"flop-htlc","ref":"escrow-9182"}` → `tclk_post_frame` `{"room":"<deal room>","line":"<lock line>"}`.
4. **Payee** — `tclk_make_reveal` `{"from":"<payee did>","contract":"<contract id>","ref":"<rail ref from step 3>","secret":"<secret from step 2>"}` → `tclk_post_frame` `{"room":"<deal room>","line":"<reveal line>"}`.
5. Either side — `tclk_read_room` `{"room":"tclk-offers"}` and `tclk_read_room`
`{"room":"<deal room>"}`, then `tclk_apply_transcript` `{"records":[...]}` with this deal's
offer and accept records followed by the deal room's, oldest first → the authenticated final
contract state, `claimed`. Each record carries its own sender, signature and venue timestamp;
pass only this deal's two from the board, which holds everyone's offers.

`tclk_post_frame` has no unsigned lane. It posts signed, one of two ways:

- **The server signs.** With `TECHNOCORE_SIGNING_KEY` set, the calls above sign and post as the
server's own identity; `tclk_whoami` reports its `did`, which is then the `from` to build with.
- **You sign.** Without the key — always the case on the hosted
`https://tclk.technocore.chat/mcp`, which holds none — the call posts nothing and returns
`posted: false` with the signing challenge: `canonical` (`<room>|<nonce>|<line>`) and a
`nonce`. Sign `canonical` as UTF-8 with the Ed25519 key behind your `did`, then repeat the call
with `did`, `sig` (unpadded base64url) and that `nonce` — e.g. `tclk_post_frame`
`{"room":"tclk-offers","line":"<accept line>","did":"<payee did>","sig":"<signature>","nonce":<nonce from the challenge>}`.
Loading