Docs / Protocol reference
Hashing and signing
Offers and receipts are contract types, serialized as ScVal XDR, hashed with SHA-256 and signed with Ed25519. Off-chain code and the contracts hash identical bytes, and the shared test vectors fix every one of them.
Domain separation
The signed message is the SHA-256 hash of four parts, in order:
- A type tag as ASCII:
LOPW/OFFER/v1(13 bytes) orLOPW/RECEIPT/v1(15 bytes). - The network ID: the 32-byte SHA-256 of the network passphrase, so a signature for one network is invalid on another.
- The ScVal XDR of the Bond Registry address (40 bytes for a contract address), so signatures cannot be replayed against another deployment.
- The ScVal XDR of the body: the
OfferorReceiptstruct, which never contains the signature.
message = SHA-256( tag || network_id || XDR(registry) || XDR(body) )
signature = Ed25519.sign(service_signing_key, message) // over the 32-byte hash
offer_hash = message (tag LOPW/OFFER/v1, registry = offer.registry)
receipt_hash = message (tag LOPW/RECEIPT/v1, registry = the registry the Claims contract is configured with)Offers name their registry; for receipts the Claims contract uses the registry it serves, and also requires offer.registry to be that registry. The buyer SDK and the middleware pass the binding’s registry address for receipts.
What exactly gets hashed and signed
message = SHA-256( tag ‖ network_id ‖ XDR(registry) ‖ XDR(body) )
- tag 'LOPW/OFFER/v1' (ASCII)13 bytes
- network_id = SHA-256(network passphrase)32 bytes
- ScVal XDR of the registry address40 bytes
- ScVal XDR of the offer body (no signature)464 bytes
message / offer_hash (32 bytes)324ef666a1b8cefea457e24eaf0fcc583ae711242d974e20b4eb74c7b44d4384
signature (64 bytes)37a1c624c506953fecd0d76c6411c662ef3cac44bca1fb6e7b1817d787e2825b90915803a62fccbd6e0f28193e0d4c8a102092c9f84816cdd7d1cb10e3b9c108
message = SHA-256( tag ‖ network_id ‖ XDR(registry) ‖ XDR(body) )
- tag 'LOPW/RECEIPT/v1' (ASCII)15 bytes
- network_id = SHA-256(network passphrase)32 bytes
- ScVal XDR of the registry address40 bytes
- ScVal XDR of the receipt body (no signature)912 bytes
message / receipt_hash (32 bytes)781d98c00ebc497c1fe9b3272ce02b1fae56e87c3a74083b454bad1883af429c
signature (64 bytes)05df38bb643802c3609ebc227d2591e9f42645a1e90fbfb92edde4439d06a4ac4f1094b33d1a7a8500e71d32404d4a9876c70f9f26b204ef0e6928ce54e0fd07
Select a segment to see its bytes.
Byte-by-byte layout of the offer body (464 bytes, from the shared test vectors)
| Offset | Len | Bytes | Meaning |
|---|---|---|---|
| 0x0000 | 4 | 00000011 | type ScvMap (17) |
| 0x0004 | 4 | 00000001 | optional ScMap present (1) |
| 0x0008 | 4 | 0000000a | map entries 10 (keys in sorted order) |
| 0x000c | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0010 | 4 | 00000005 | length 5 |
| 0x0014 | 8 | 6173736574000000 | 'asset' + 3 pad |
| 0x001c | 4 | 00000012 | value type ScvAddress (18) |
| 0x0020 | 4 | 00000001 | address type contract (1) |
| 0x0024 | 32 | 5045cd5ec0729a768fd5ad02505852df… | 32-byte key / contract id |
| 0x0044 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0048 | 4 | 0000000b | length 11 |
| 0x004c | 12 | 6b65795f76657273696f6e00 | 'key_version' + 1 pad |
| 0x0058 | 4 | 00000003 | value type ScvU32 (3) |
| 0x005c | 4 | 00000001 | u32 value 1 |
| 0x0060 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0064 | 4 | 00000009 | length 9 |
| 0x0068 | 12 | 6d61785f636c61696d000000 | 'max_claim' + 3 pad |
| 0x0074 | 4 | 0000000a | value type ScvI128 (10) |
| 0x0078 | 16 | 00000000000000000000000002faf080 | i128 value 50000000 (hi 8 bytes, lo 8 bytes) |
| 0x0088 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x008c | 4 | 0000000b | length 11 |
| 0x0090 | 12 | 6f666665725f6e6f6e636500 | 'offer_nonce' + 1 pad |
| 0x009c | 4 | 0000000d | value type ScvBytes (13) |
| 0x00a0 | 4 | 00000020 | length 32 |
| 0x00a4 | 32 | 22222222222222222222222222222222… | 2222222222222222222222222222222222222222222222222222222222222222 |
| 0x00c4 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x00c8 | 4 | 00000005 | length 5 |
| 0x00cc | 8 | 7072696365000000 | 'price' + 3 pad |
| 0x00d4 | 4 | 0000000a | value type ScvI128 (10) |
| 0x00d8 | 16 | 000000000000000000000000000186a0 | i128 value 100000 (hi 8 bytes, lo 8 bytes) |
| 0x00e8 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x00ec | 4 | 00000008 | length 8 |
| 0x00f0 | 8 | 7265676973747279 | 'registry' |
| 0x00f8 | 4 | 00000012 | value type ScvAddress (18) |
| 0x00fc | 4 | 00000001 | address type contract (1) |
| 0x0100 | 32 | 346c4ca8305685f347e061e8fc049df3… | 32-byte key / contract id |
| 0x0120 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0124 | 4 | 0000000a | length 10 |
| 0x0128 | 12 | 736572766963655f69640000 | 'service_id' + 2 pad |
| 0x0134 | 4 | 0000000d | value type ScvBytes (13) |
| 0x0138 | 4 | 00000020 | length 32 |
| 0x013c | 32 | 11111111111111111111111111111111… | 1111111111111111111111111111111111111111111111111111111111111111 |
| 0x015c | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0160 | 4 | 0000000a | length 10 |
| 0x0164 | 12 | 7465726d735f686173680000 | 'terms_hash' + 2 pad |
| 0x0170 | 4 | 0000000d | value type ScvBytes (13) |
| 0x0174 | 4 | 00000020 | length 32 |
| 0x0178 | 32 | 7261e3aa021d521d86d07e46dfc96d43… | 7261e3aa021d521d86d07e46dfc96d43978de8bff84651d71083d77a49514d35 |
| 0x0198 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x019c | 4 | 0000000b | length 11 |
| 0x01a0 | 12 | 76616c69645f756e74696c00 | 'valid_until' + 1 pad |
| 0x01ac | 4 | 00000005 | value type ScvU64 (5) |
| 0x01b0 | 8 | 000000006ac07dc0 | u64 value 1791000000 |
| 0x01b8 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x01bc | 4 | 00000007 | length 7 |
| 0x01c0 | 8 | 76657273696f6e00 | 'version' + 1 pad |
| 0x01c8 | 4 | 00000003 | value type ScvU32 (3) |
| 0x01cc | 4 | 00000001 | u32 value 1 |
Byte-by-byte layout of the receipt body (912 bytes, from the shared test vectors)
| Offset | Len | Bytes | Meaning |
|---|---|---|---|
| 0x0000 | 4 | 00000011 | type ScvMap (17) |
| 0x0004 | 4 | 00000001 | optional ScMap present (1) |
| 0x0008 | 4 | 0000000e | map entries 14 (keys in sorted order) |
| 0x000c | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0010 | 4 | 00000005 | length 5 |
| 0x0014 | 8 | 6275796572000000 | 'buyer' + 3 pad |
| 0x001c | 4 | 00000012 | value type ScvAddress (18) |
| 0x0020 | 4 | 00000000 | address type account (0) |
| 0x0024 | 4 | 00000000 | public key type 0 |
| 0x0028 | 32 | 2d2c0646f9a183fff28b3ec7959663e8… | 32-byte key / contract id |
| 0x0048 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x004c | 4 | 0000000a | length 10 |
| 0x0050 | 12 | 646174615f61735f6f660000 | 'data_as_of' + 2 pad |
| 0x005c | 4 | 00000005 | value type ScvU64 (5) |
| 0x0060 | 8 | 000000006ac05c1e | u64 value 1790991390 |
| 0x0068 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x006c | 4 | 0000000b | length 11 |
| 0x0070 | 12 | 6b65795f76657273696f6e00 | 'key_version' + 1 pad |
| 0x007c | 4 | 00000003 | value type ScvU32 (3) |
| 0x0080 | 4 | 00000001 | u32 value 1 |
| 0x0084 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0088 | 4 | 0000000a | length 10 |
| 0x008c | 12 | 6f666665725f686173680000 | 'offer_hash' + 2 pad |
| 0x0098 | 4 | 0000000d | value type ScvBytes (13) |
| 0x009c | 4 | 00000020 | length 32 |
| 0x00a0 | 32 | 324ef666a1b8cefea457e24eaf0fcc58… | 324ef666a1b8cefea457e24eaf0fcc583ae711242d974e20b4eb74c7b44d4384 |
| 0x00c0 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x00c4 | 4 | 00000006 | length 6 |
| 0x00c8 | 8 | 6f7261636c650000 | 'oracle' + 2 pad |
| 0x00d0 | 4 | 00000010 | value type ScvVec (16) |
| 0x00d4 | 4 | 00000001 | optional ScVec present (1) |
| 0x00d8 | 4 | 00000002 | vec length 2 |
| 0x00dc | 4 | 0000000f | [0] type ScvSymbol (15) |
| 0x00e0 | 4 | 00000004 | length 4 |
| 0x00e4 | 4 | 536f6d65 | 'Some' |
| 0x00e8 | 4 | 00000011 | [1] type ScvMap (17) |
| 0x00ec | 4 | 00000001 | optional ScMap present (1) |
| 0x00f0 | 4 | 00000005 | map entries 5 (keys in sorted order) |
| 0x00f4 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x00f8 | 4 | 0000000c | length 12 |
| 0x00fc | 12 | 6f7261636c655f6173736574 | 'oracle_asset' |
| 0x0108 | 4 | 00000010 | value type ScvVec (16) |
| 0x010c | 4 | 00000001 | optional ScVec present (1) |
| 0x0110 | 4 | 00000002 | vec length 2 |
| 0x0114 | 4 | 0000000f | [0] type ScvSymbol (15) |
| 0x0118 | 4 | 00000005 | length 5 |
| 0x011c | 8 | 4f74686572000000 | 'Other' + 3 pad |
| 0x0124 | 4 | 0000000f | [1] type ScvSymbol (15) |
| 0x0128 | 4 | 00000003 | length 3 |
| 0x012c | 4 | 45555200 | 'EUR' + 1 pad |
| 0x0130 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0134 | 4 | 0000000f | length 15 |
| 0x0138 | 16 | 6f7261636c655f636f6e747261637400 | 'oracle_contract' + 1 pad |
| 0x0148 | 4 | 00000012 | value type ScvAddress (18) |
| 0x014c | 4 | 00000001 | address type contract (1) |
| 0x0150 | 32 | a5271e615ed64b50410903d6a4a2d394… | 32-byte key / contract id |
| 0x0170 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0174 | 4 | 0000000c | length 12 |
| 0x0178 | 12 | 6f7261636c655f7072696365 | 'oracle_price' |
| 0x0184 | 4 | 0000000a | value type ScvI128 (10) |
| 0x0188 | 16 | 000000000000000000006653c493507a | i128 value 112509966307450 (hi 8 bytes, lo 8 bytes) |
| 0x0198 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x019c | 4 | 00000010 | length 16 |
| 0x01a0 | 16 | 6f7261636c655f74696d657374616d70 | 'oracle_timestamp' |
| 0x01b0 | 4 | 00000005 | value type ScvU64 (5) |
| 0x01b4 | 8 | 000000006ac05bc4 | u64 value 1790991300 |
| 0x01bc | 4 | 0000000f | key type ScvSymbol (15) |
| 0x01c0 | 4 | 0000000e | length 14 |
| 0x01c4 | 16 | 7265706f727465645f76616c75650000 | 'reported_value' + 2 pad |
| 0x01d4 | 4 | 0000000a | value type ScvI128 (10) |
| 0x01d8 | 16 | 000000000000000000006653c48e9f80 | i128 value 112509966000000 (hi 8 bytes, lo 8 bytes) |
| 0x01e8 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x01ec | 4 | 00000011 | length 17 |
| 0x01f0 | 20 | 7061796d656e745f7265666572656e63… | 'payment_reference' + 3 pad |
| 0x0204 | 4 | 0000000d | value type ScvBytes (13) |
| 0x0208 | 4 | 00000020 | length 32 |
| 0x020c | 32 | 33333333333333333333333333333333… | 3333333333333333333333333333333333333333333333333333333333333333 |
| 0x022c | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0230 | 4 | 0000000c | length 12 |
| 0x0234 | 12 | 726571756573745f68617368 | 'request_hash' |
| 0x0240 | 4 | 0000000d | value type ScvBytes (13) |
| 0x0244 | 4 | 00000020 | length 32 |
| 0x0248 | 32 | 3ad9d6da5164b75376fd879984ceece7… | 3ad9d6da5164b75376fd879984ceece7f1b32b76e987d3fc663a04c549664c0e |
| 0x0268 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x026c | 4 | 0000000e | length 14 |
| 0x0270 | 16 | 726573706f6e73655f62797465730000 | 'response_bytes' + 2 pad |
| 0x0280 | 4 | 00000003 | value type ScvU32 (3) |
| 0x0284 | 4 | 00000026 | u32 value 38 |
| 0x0288 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x028c | 4 | 0000000d | length 13 |
| 0x0290 | 16 | 726573706f6e73655f68617368000000 | 'response_hash' + 3 pad |
| 0x02a0 | 4 | 0000000d | value type ScvBytes (13) |
| 0x02a4 | 4 | 00000020 | length 32 |
| 0x02a8 | 32 | 97b8da906999282c9ddc6e4f842df5c3… | 97b8da906999282c9ddc6e4f842df5c37f0db9a607436f582815a2fbe85eed82 |
| 0x02c8 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x02cc | 4 | 00000009 | length 9 |
| 0x02d0 | 12 | 7365727665645f6174000000 | 'served_at' + 3 pad |
| 0x02dc | 4 | 00000005 | value type ScvU64 (5) |
| 0x02e0 | 8 | 000000006ac05c28 | u64 value 1790991400 |
| 0x02e8 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x02ec | 4 | 0000000a | length 10 |
| 0x02f0 | 12 | 736572766963655f69640000 | 'service_id' + 2 pad |
| 0x02fc | 4 | 0000000d | value type ScvBytes (13) |
| 0x0300 | 4 | 00000020 | length 32 |
| 0x0304 | 32 | 11111111111111111111111111111111… | 1111111111111111111111111111111111111111111111111111111111111111 |
| 0x0324 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0328 | 4 | 00000006 | length 6 |
| 0x032c | 8 | 7374617475730000 | 'status' + 2 pad |
| 0x0334 | 4 | 00000003 | value type ScvU32 (3) |
| 0x0338 | 4 | 000000c8 | u32 value 200 |
| 0x033c | 4 | 0000000f | key type ScvSymbol (15) |
| 0x0340 | 4 | 0000000a | length 10 |
| 0x0344 | 12 | 7465726d735f686173680000 | 'terms_hash' + 2 pad |
| 0x0350 | 4 | 0000000d | value type ScvBytes (13) |
| 0x0354 | 4 | 00000020 | length 32 |
| 0x0358 | 32 | 7261e3aa021d521d86d07e46dfc96d43… | 7261e3aa021d521d86d07e46dfc96d43978de8bff84651d71083d77a49514d35 |
| 0x0378 | 4 | 0000000f | key type ScvSymbol (15) |
| 0x037c | 4 | 00000007 | length 7 |
| 0x0380 | 8 | 76657273696f6e00 | 'version' + 1 pad |
| 0x0388 | 4 | 00000003 | value type ScvU32 (3) |
| 0x038c | 4 | 00000001 | u32 value 1 |
ScVal XDR encoding rules
The canonical bytes are the ScVal XDR of each Soroban #[contracttype], produced identically by the contracts and by @lopw/protocol:
- A struct is a
ScvMapwhose keys areScvSymbolfield names in sorted order, soassetcomes beforekey_versionandversionis last. - A unit enum variant is a
ScvVecof one symbol:[Symbol("None")]. A tuple variant is[Symbol("Some"), value]. Optional blocks (Terms.oracle,Receipt.oracle) use these variants because contract types cannot holdOption<struct>. u32isScvU32(4 bytes),u64isScvU64(8 bytes),i128isScvI128(16 bytes, high then low), 32-byte values areScvBytes(length prefix then bytes, padded to 4), addresses areScvAddress.- The signed body and the signature are separate types:
SignedOffer { offer, signature }andSignedReceipt { receipt, signature }. “The struct with its signature left out” is therefore simply the body’s XDR, with no special-case encoding.
On the wire, SignedOffer and SignedReceipt travel as base64 ScVal XDR (encodeSignedOffer, decodeSignedReceipt and friends in @lopw/protocol).
Terms hash and service ID
terms_hash = SHA-256( XDR(terms) ) // no domain separation; keyed by (service_id, terms_hash) in the registry
service_id = SHA-256( XDR(registry contract address) || XDR(counter as u64) ) // counter starts at 0 and increments per registrationterms_hash can be computed off-chain with termsHash(terms) or on-chain with the registry’s read-only terms_hash(terms).
Request and response hashes
request_hash = SHA-256( METHOD + "\n" + path?query + "\n" + lowercase-hex(SHA-256(body)) ) // METHOD upper-case; empty body hashes too
response_hash = SHA-256( response body as sent )The middleware hashes the raw request body and originalUrl; the buyer SDK recomputes both hashes from what it actually sent and received and marks the response untrusted on any mismatch. Test-vector values: GET /v1/quote?pair=EUR-USD with an empty body gives request_hash 3ad9d6da5164b75376fd879984ceece7f1b32b76e987d3fc663a04c549664c0e; the 38-byte body {"pair":"EUR-USD","rate":"1.12509966"} gives response_hash 97b8da906999282c9ddc6e4f842df5c37f0db9a607436f582815a2fbe85eed82.
Signing keys and rotation
Each service registers an Ed25519 public key separate from the seller’s account. The server holds the 32-byte seed; it can sign offers and receipts but cannot move stake. Keys are stored by key_version, starting at 1.
rotate_keystamps the previous key withretired_at = nowand stores the new key as version + 1.- A receipt signed with version
kis accepted only ifretired_at(k) == 0orreceipt.served_at < retired_at(k); otherwise the claim fails withKeyRetired. Old receipts stay claimable; a leaked old key cannot produce new ones. - Offers are verified against the key at
offer.key_version; an unknown version fails withBadSignature.
Invalid signatures
Soroban’s ed25519_verify aborts the transaction rather than returning an error. A claim carrying a signature that does not verify therefore fails as a host error, not with the BadSignature code, which is reserved for a missing key version. The outcome is the same: the claim is rejected, nothing is recorded and the seller is not penalised. Off-chain, verifyOffer and verifyReceipt return false and the buyer SDK records receipt signature invalid as a problem.
Replay protection
- A paid claim is recorded under
(service_id, payment_reference). A second claim for the same payment fails withAlreadyClaimed, whichever receipt it presents. A rejected claim records nothing and does not block a later valid claim. offer_noncemakes every offer hash unique. The buyer SDK refuses an offer whose nonce it has already paid.valid_untilbounds how long an offer can be paid. The SDK refuses expired offers.- The network ID and registry address in every hash stop cross-network and cross-deployment replay.
- Claim records live in persistent storage with a lifetime extended on every access (threshold 30 days, extended to 60 days, in ledgers), far longer than any claim window.
Shared test vectors
v1.json fixes the exact bytes of version 1 hashing and signing: the terms, offer and receipt from this page, their XDR, hashes and signatures, computed by the contract types and reproduced byte for byte by the TypeScript package. Any change to an encoding shows up as a diff. The signing seed in the vectors is a fixed test value and must never be used for a real service.
| Vector | Value |
|---|---|
| Network passphrase | Test SDF Network ; September 2015 |
network_id | cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472 |
terms_hash | 7261e3aa021d521d86d07e46dfc96d43978de8bff84651d71083d77a49514d35 |
offer_hash | 324ef666a1b8cefea457e24eaf0fcc583ae711242d974e20b4eb74c7b44d4384 |
receipt_hash | 781d98c00ebc497c1fe9b3272ce02b1fae56e87c3a74083b454bad1883af429c |
receipt_without_oracle_hash | 7a186bf09547ac7f8485972d0da38d944dcddfe35c4ec26448e4dfc255df7194 |
import { offerHash, receiptHash, termsHash, requestHash, signOffer, verifyReceipt, signingKeyFromSeed } from "@lopw/protocol";
const kp = signingKeyFromSeed(seedHex);
const signed = signOffer(offer, kp, network.passphrase); // { offer, signature }
const ok = verifyReceipt(signedReceipt, key.public_key, network.passphrase, network.registry);