LOPWDocumentation

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:

  1. A type tag as ASCII: LOPW/OFFER/v1 (13 bytes) or LOPW/RECEIPT/v1 (15 bytes).
  2. The network ID: the 32-byte SHA-256 of the network passphrase, so a signature for one network is invalid on another.
  3. The ScVal XDR of the Bond Registry address (40 bytes for a contract address), so signatures cannot be replayed against another deployment.
  4. The ScVal XDR of the body: the Offer or Receipt struct, which never contains the signature.
Signing
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) )

  1. tag 'LOPW/OFFER/v1' (ASCII)13 bytes
  2. network_id = SHA-256(network passphrase)32 bytes
  3. ScVal XDR of the registry address40 bytes
  4. ScVal XDR of the offer body (no signature)464 bytes

message / offer_hash (32 bytes)324ef666a1b8cefea457e24eaf0fcc583ae711242d974e20b4eb74c7b44d4384

signature (64 bytes)37a1c624c506953fecd0d76c6411c662ef3cac44bca1fb6e7b1817d787e2825b90915803a62fccbd6e0f28193e0d4c8a102092c9f84816cdd7d1cb10e3b9c108

Select a segment to see its bytes.

Byte-by-byte layout of the offer body (464 bytes, from the shared test vectors)
OffsetLenBytesMeaning
0x0000400000011 type ScvMap (17)
0x0004400000001optional ScMap present (1)
0x000840000000amap entries 10 (keys in sorted order)
0x000c40000000fkey type ScvSymbol (15)
0x0010400000005length 5
0x001486173736574000000'asset' + 3 pad
0x001c400000012value type ScvAddress (18)
0x0020400000001address type contract (1)
0x0024325045cd5ec0729a768fd5ad02505852df…32-byte key / contract id
0x004440000000fkey type ScvSymbol (15)
0x004840000000blength 11
0x004c126b65795f76657273696f6e00'key_version' + 1 pad
0x0058400000003value type ScvU32 (3)
0x005c400000001u32 value 1
0x006040000000fkey type ScvSymbol (15)
0x0064400000009length 9
0x0068126d61785f636c61696d000000'max_claim' + 3 pad
0x007440000000avalue type ScvI128 (10)
0x00781600000000000000000000000002faf080i128 value 50000000 (hi 8 bytes, lo 8 bytes)
0x008840000000fkey type ScvSymbol (15)
0x008c40000000blength 11
0x0090126f666665725f6e6f6e636500'offer_nonce' + 1 pad
0x009c40000000dvalue type ScvBytes (13)
0x00a0400000020length 32
0x00a43222222222222222222222222222222222…2222222222222222222222222222222222222222222222222222222222222222
0x00c440000000fkey type ScvSymbol (15)
0x00c8400000005length 5
0x00cc87072696365000000'price' + 3 pad
0x00d440000000avalue type ScvI128 (10)
0x00d816000000000000000000000000000186a0i128 value 100000 (hi 8 bytes, lo 8 bytes)
0x00e840000000fkey type ScvSymbol (15)
0x00ec400000008length 8
0x00f087265676973747279'registry'
0x00f8400000012value type ScvAddress (18)
0x00fc400000001address type contract (1)
0x010032346c4ca8305685f347e061e8fc049df3…32-byte key / contract id
0x012040000000fkey type ScvSymbol (15)
0x012440000000alength 10
0x012812736572766963655f69640000'service_id' + 2 pad
0x013440000000dvalue type ScvBytes (13)
0x0138400000020length 32
0x013c3211111111111111111111111111111111…1111111111111111111111111111111111111111111111111111111111111111
0x015c40000000fkey type ScvSymbol (15)
0x016040000000alength 10
0x0164127465726d735f686173680000'terms_hash' + 2 pad
0x017040000000dvalue type ScvBytes (13)
0x0174400000020length 32
0x0178327261e3aa021d521d86d07e46dfc96d43…7261e3aa021d521d86d07e46dfc96d43978de8bff84651d71083d77a49514d35
0x019840000000fkey type ScvSymbol (15)
0x019c40000000blength 11
0x01a01276616c69645f756e74696c00'valid_until' + 1 pad
0x01ac400000005value type ScvU64 (5)
0x01b08000000006ac07dc0u64 value 1791000000
0x01b840000000fkey type ScvSymbol (15)
0x01bc400000007length 7
0x01c0876657273696f6e00'version' + 1 pad
0x01c8400000003value type ScvU32 (3)
0x01cc400000001u32 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:

On the wire, SignedOffer and SignedReceipt travel as base64 ScVal XDR (encodeSignedOffer, decodeSignedReceipt and friends in @lopw/protocol).

Terms hash and service ID

Text
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 registration

terms_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

Text
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.

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

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.

VectorValue
Network passphraseTest SDF Network ; September 2015
network_idcee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472
terms_hash7261e3aa021d521d86d07e46dfc96d43978de8bff84651d71083d77a49514d35
offer_hash324ef666a1b8cefea457e24eaf0fcc583ae711242d974e20b4eb74c7b44d4384
receipt_hash781d98c00ebc497c1fe9b3272ce02b1fae56e87c3a74083b454bad1883af429c
receipt_without_oracle_hash7a186bf09547ac7f8485972d0da38d944dcddfe35c4ec26448e4dfc255df7194
TypeScript
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);