# Hashing and signing

> Exactly which bytes are hashed and signed in LOPW: ScVal XDR encoding, the domain-separated message, request and terms hashes, service IDs, key rotation, replay protection and the shared test vectors.

*LOPW documentation · Protocol reference.* Canonical page: https://lopw.com/docs/protocol/hashing-and-signing.html · This Markdown twin: https://lopw.com/docs/protocol/hashing-and-signing.md · Index of all pages: https://lopw.com/llms.txt · Everything in one file: https://lopw.com/llms-full.txt

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*

```text
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.

```text
message      = SHA-256( tag || network_id || ScVal-XDR(registry) || ScVal-XDR(body) )
tag          = "LOPW/OFFER/v1" (13 bytes) or "LOPW/RECEIPT/v1" (15 bytes), ASCII
network_id   = SHA-256(network passphrase), 32 bytes
registry     = ScvAddress XDR of the Bond Registry contract, 40 bytes
body         = ScvMap XDR of Offer (464 bytes in the test vectors) or Receipt (912 bytes)
signature    = Ed25519.sign(service_signing_key, message), 64 bytes
```

Test-vector values (network Test SDF Network ; September 2015, registry `CA2GYTFIGBLIL42H4BQ6R7AETXZ5D5Z357CLWXBDBUXP6ESCGGRDFHTC`):

| Value | Hex |
| --- | --- |
| network_id | `cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472` |
| offer_hash | `324ef666a1b8cefea457e24eaf0fcc583ae711242d974e20b4eb74c7b44d4384` |
| offer_signature | `37a1c624c506953fecd0d76c6411c662ef3cac44bca1fb6e7b1817d787e2825b90915803a62fccbd6e0f28193e0d4c8a102092c9f84816cdd7d1cb10e3b9c108` |
| receipt_hash | `781d98c00ebc497c1fe9b3272ce02b1fae56e87c3a74083b454bad1883af429c` |
| receipt_signature | `05df38bb643802c3609ebc227d2591e9f42645a1e90fbfb92edde4439d06a4ac4f1094b33d1a7a8500e71d32404d4a9876c70f9f26b204ef0e6928ce54e0fd07` |

## 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 `ScvMap` whose keys are `ScvSymbol` field names **in sorted order**, so `asset` comes before `key_version` and `version` is last.
- A unit enum variant is a `ScvVec` of one symbol: `[Symbol("None")]`. A tuple variant is `[Symbol("Some"), value]`. Optional blocks (`Terms.oracle`, `Receipt.oracle`) use these variants because contract types cannot hold `Option`.
- `u32` is `ScvU32` (4 bytes), `u64` is `ScvU64` (8 bytes), `i128` is `ScvI128` (16 bytes, high then low), 32-byte values are `ScvBytes` (length prefix then bytes, padded to 4), addresses are `ScvAddress`.
- The signed body and the signature are separate types: `SignedOffer { offer, signature }` and `SignedReceipt { 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

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

- `rotate_key` stamps the previous key with `retired_at = now` and stores the new key as version + 1.
- A receipt signed with version `k` is accepted only if `retired_at(k) == 0` or `receipt.served_at < retired_at(k)`; otherwise the claim fails with `KeyRetired`. 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 with `BadSignature`.

## 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 with `AlreadyClaimed`, whichever receipt it presents. A *rejected* claim records nothing and does not block a later valid claim.
- `offer_nonce` makes every offer hash unique. The buyer SDK refuses an offer whose nonce it has already paid.
- `valid_until` bounds 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](https://lopw.com/docs/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` |

```ts
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);
```
