diff --git a/LICENSE b/LICENSE
index 6b79e20..d22aaf4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,202 +1,21 @@
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright 2026 WAVE, Inc.
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
-
+MIT License
+
+Copyright (c) 2026 WAVE Online, LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index ba29503..5c19797 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# wave Dispatch — edge worker
+# WAVE Dispatch — edge worker
**Local-first AI routing.** Send every request to the cheapest *capable* model — your local models first
($0, your infra), escalating to a frontier (Claude / GPT / Gemini / …) only when confidence is low.
@@ -26,7 +26,7 @@ Most agent and LLM workloads send *everything* to a frontier model — including
local model handles perfectly. You pay frontier prices for work that didn't need it, and your prompts leave
your infra on every call.
-wave Dispatch puts a tiny classifier at the edge. It embeds each request (Workers AI, `bge-base-en`),
+WAVE Dispatch puts a tiny classifier at the edge. It embeds each request (Workers AI, `bge-base-en`),
runs a matmul over bundled weights, and returns a **routing decision**: which tier should handle this, with
a confidence and margin. Your runtime does the rest — local models on your hardware, frontier only when the
classifier isn't confident enough.
diff --git a/sdk/README.md b/sdk/README.md
index a1fd2b7..defe13f 100644
--- a/sdk/README.md
+++ b/sdk/README.md
@@ -1,4 +1,4 @@
-# wave Dispatch SDKs
+# WAVE Dispatch SDKs
Thin clients for the edge API (`POST /` → `{route, probability, margin, forward}`; `execute:true` for
edge-local answers; `vector:[768]` for matmul-only; bearer license or x402). Your keys + infra stay yours.
diff --git a/sdk/js/README.md b/sdk/js/README.md
index fc20400..180e679 100644
--- a/sdk/js/README.md
+++ b/sdk/js/README.md
@@ -1,6 +1,6 @@
# @wave-av/dispatch
-JS/TS client for [wave Dispatch](https://dispatch.wave.online) — route each request to the cheapest
+JS/TS client for [WAVE Dispatch](https://dispatch.wave.online) — route each request to the cheapest
capable model (local-first; escalate to your frontier model only when needed). BYO keys + infra.
```js
diff --git a/sdk/js/attest.test.js b/sdk/js/attest.test.js
new file mode 100644
index 0000000..48d6af6
--- /dev/null
+++ b/sdk/js/attest.test.js
@@ -0,0 +1,56 @@
+// Zero-dep tests (node:test) for the SDK's offline attestation verifier. The decisive test is
+// CROSS-LANGUAGE: an attestation signed by the Python signer (attest.py) must verify here byte-for-byte —
+// that's what makes "sign in the runtime, verify in any SDK, trust no one" real.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { verifyAttestation, attestationTruncated, canonicalAttestation } from "./index.js";
+
+// The exact vector emitted by attest.py (also pinned in edge-router/context-attest.test.ts).
+const BASE = {
+ v: "wave.context-attestation/v0", ts: 1700000000, model: "qwen3:30b", source: "café.md",
+ chunk: 2, chunk_sha: "deadbeef", chunk_chars: 2980, num_ctx: 8192, prompt_sha: "cafe1234",
+ kept: 3, dropped_hallucinated: 1,
+};
+const PY_CANONICAL =
+ '{"chunk":2,"chunk_chars":2980,"chunk_sha":"deadbeef","dropped_hallucinated":1,"kept":3,' +
+ '"model":"qwen3:30b","num_ctx":8192,"prompt_sha":"cafe1234","source":"caf\\u00e9.md",' +
+ '"ts":1700000000,"v":"wave.context-attestation/v0"}';
+const PY_PUBKEY = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
+const PY_SIG =
+ "68017540b4c3b060e51faab64c28f4e1ea6994d3781fec14878e73052046d78d" +
+ "05757a8287af644b245b10274ba3b60329ed216472839d7573debf4256e9730b";
+
+test("canonical string matches the Python signer byte-for-byte (café -> caf\\u00e9)", () => {
+ assert.equal(canonicalAttestation(BASE), PY_CANONICAL);
+});
+
+test("verifies a Python Ed25519 signature with NO secret (trustless, offline)", async () => {
+ assert.equal(await verifyAttestation({ ...BASE, alg: "ed25519", sig: PY_SIG, pubkey: PY_PUBKEY }), true);
+});
+
+test("rejects a tampered field against the Python signature", async () => {
+ assert.equal(await verifyAttestation({ ...BASE, kept: 999, alg: "ed25519", sig: PY_SIG, pubkey: PY_PUBKEY }), false);
+});
+
+test("HMAC: verifies with the key, fails with the wrong one, null without it", async () => {
+ const subtle = globalThis.crypto.subtle;
+ const enc = new TextEncoder();
+ const key = await subtle.importKey("raw", enc.encode("k"), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
+ const sig = [...new Uint8Array(await subtle.sign("HMAC", key, enc.encode(canonicalAttestation(BASE))))]
+ .map((x) => x.toString(16).padStart(2, "0")).join("");
+ const att = { ...BASE, alg: "hmac-sha256", sig };
+ assert.equal(await verifyAttestation(att, { hmacKey: "k" }), true);
+ assert.equal(await verifyAttestation(att, { hmacKey: "wrong" }), false);
+ assert.equal(await verifyAttestation(att), null); // HMAC without our key -> uncheckable
+});
+
+test("unsigned / no-alg -> null (not false)", async () => {
+ assert.equal(await verifyAttestation({ ...BASE }), null);
+ assert.equal(await verifyAttestation({ ...BASE, alg: "none", sig: null }), null);
+});
+
+test("truncation is derivable from two hashes", () => {
+ assert.equal(attestationTruncated({ chunk_sha: "a", prompt_sha: "b" }), true);
+ assert.equal(attestationTruncated({ chunk_sha: "a", prompt_sha: "a" }), false);
+ assert.equal(attestationTruncated({ chunk_sha: "a" }), null);
+});
diff --git a/sdk/js/index.d.ts b/sdk/js/index.d.ts
index 8e5d850..c654745 100644
--- a/sdk/js/index.d.ts
+++ b/sdk/js/index.d.ts
@@ -1,4 +1,4 @@
-// Type declarations for @wave-av/dispatch — the JS/TS client for wave Dispatch.
+// Type declarations for @wave-av/dispatch — the JS/TS client for WAVE Dispatch.
// Hand-written to match index.js exactly (the JS is the source of truth). A WAVE product.
/** Routing decision returned by `route` / `execute` / `routeVector` (POST `/`). */
@@ -116,7 +116,7 @@ export interface WalletHookConfig {
fetchImpl?: FetchImpl;
}
-/** Client for the wave Dispatch edge API. */
+/** Client for the WAVE Dispatch edge API. */
export declare class Dispatch {
license?: string;
endpoint: string;
@@ -154,4 +154,74 @@ export declare class Dispatch {
): Promise
;
}
+/** A WAVE context-attestation — signed proof of what a model saw. See docs/attest-hotpath.md. */
+export interface ContextAttestation {
+ v?: string; ts?: number; model?: string; source?: string; chunk?: number;
+ chunk_sha?: string; chunk_chars?: number; num_ctx?: number; prompt_sha?: string;
+ kept?: number; dropped_hallucinated?: number;
+ alg?: "none" | "hmac-sha256" | "ed25519"; sig?: string | null; pubkey?: string; key_id?: string;
+}
+
+/** Canonical signing string for an attestation — byte-for-byte identical to the edge/Python signer. */
+export function canonicalAttestation(att: ContextAttestation): string;
+
+/** True if the model saw less than the full prompt (chunk_sha !== prompt_sha); null if undeterminable. */
+export function attestationTruncated(att: ContextAttestation): boolean | null;
+
+/**
+ * Verify a context-attestation offline & trustless. Returns `true`/`false` when signed & checkable, or
+ * `null` when unsigned (or an HMAC attestation without its key). Ed25519 needs no secret (self-describing).
+ */
+export function verifyAttestation(att: ContextAttestation, opts?: { hmacKey?: string; registry?: TrustedRegistry }): Promise;
+
+/** A WAVE Payment-Receipt — dispatch's signed proof it verified a settlement (the money half of the two receipts). */
+export interface PaymentReceipt {
+ v?: string; ts?: number; protocol?: string; mode?: string; resource?: string;
+ network?: string; asset?: string | null; amount_atomic?: string; pay_to?: string | null;
+ tx_hash?: string | null; verified?: boolean;
+ alg?: "none" | "hmac-sha256" | "ed25519"; sig?: string | null; pubkey?: string; key_id?: string;
+}
+
+/** Canonical signing string for a Payment-Receipt — byte-for-byte identical to the edge signer. */
+export function canonicalPaymentReceipt(r: PaymentReceipt): string;
+
+/**
+ * Verify a Payment-Receipt offline & trustless. Returns `true`/`false` when signed & checkable, or `null`
+ * when unsigned (or an HMAC receipt without its key). Ed25519 needs no secret (self-describing).
+ */
+export function verifyPaymentReceipt(r: PaymentReceipt, opts?: { hmacKey?: string; registry?: TrustedRegistry }): Promise;
+
+// ── trusted-key registry (E4.followup): trust's SECOND axis — "is the signer WAVE's key?" ─────────────
+
+/** An immutable set of trusted signer pubkeys, built by {@link makeRegistry}. */
+export interface TrustedRegistry {
+ /** True if `pubkeyHex` (case-insensitive) is a trusted key. */
+ has(pubkeyHex: string): boolean;
+ /** Number of distinct trusted keys (0 => an empty registry, which decides nothing). */
+ size: number;
+}
+
+/** A `/.well-known/wave-keys.json` payload (or any `{keys:[...]}`) accepted by {@link makeRegistry}. */
+export interface WaveKeysPayload {
+ keys: Array;
+ [k: string]: unknown;
+}
+
+/**
+ * Build a {@link TrustedRegistry} from `{pubkey, key_id?}` entries (or bare pubkey-hex strings), or a
+ * `/.well-known/wave-keys.json` payload. Malformed (non-hex / odd-length) entries are dropped.
+ */
+export function makeRegistry(
+ entries?: WaveKeysPayload | Array | null
+): TrustedRegistry;
+
+/**
+ * Is the signer's key one WAVE published? `true` = ed25519 key in `registry` with an honest key_id; `false`
+ * = a valid-shaped key that is NOT trusted (or a key_id that lies about its pubkey); `null` = cannot decide
+ * (no/empty registry, unsigned, "none", or HMAC). Does NOT verify the signature — that's verify*().
+ */
+export function trustedSigner(
+ r: PaymentReceipt | ContextAttestation, registry?: TrustedRegistry | null
+): Promise;
+
export default Dispatch;
diff --git a/sdk/js/index.js b/sdk/js/index.js
index e949f18..0964215 100644
--- a/sdk/js/index.js
+++ b/sdk/js/index.js
@@ -1,4 +1,4 @@
-// wave Dispatch — JS/TS client for the edge API. Route each request to the cheapest capable model
+// WAVE Dispatch — JS/TS client for the edge API. Route each request to the cheapest capable model
// (local-first; escalate to your frontier model only when needed). Your keys + infra stay yours.
const DEFAULT_ENDPOINT = "https://dispatch.wave.online";
const DEFAULT_AGENTS_ENDPOINT = "https://dispatch-agents.wave.online"; // stateful sidecar: savings + subscriptions
@@ -211,4 +211,75 @@ async function _signCdpJwt(creds, accept) {
// canonical, camelCase aliases coerced via _normCreds.
Dispatch.signCdpJwt = (creds, accept) => _signCdpJwt(_normCreds(creds), accept);
+// ── Context-attestation verification (offline + trustless) ─────────────────────────────────────────
+// Verify a WAVE context-attestation WITHOUT trusting WAVE: an Ed25519 attestation embeds its own public
+// key, so this confirms "what the model saw" (and whether the prompt was truncated) using only WebCrypto —
+// no network, no secret, no dependency. HMAC attestations need the shared secret (pass {hmacKey}). The
+// canonical form is byte-identical to the edge signer (edge-router/context-attest.ts) and the Python signer,
+// pinned by the cross-language vector in attest.test.js. See docs/attest-hotpath.md.
+const ATTEST_FIELDS = ["v", "ts", "model", "source", "chunk", "chunk_sha", "chunk_chars", "num_ctx", "prompt_sha", "kept", "dropped_hallucinated"];
+
+function _asciiEscape(s) {
+ // Python's json.dumps(ensure_ascii=True) escapes every non-ASCII char as \uXXXX; JSON.stringify does not.
+ return s.replace(/[-]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
+}
+
+/** Canonical signing string for an attestation — byte-for-byte identical to the edge/Python signer. */
+export function canonicalAttestation(att) {
+ const sorted = {};
+ for (const k of [...ATTEST_FIELDS].sort()) sorted[k] = att?.[k] ?? null; // missing -> null, like Python None
+ return _asciiEscape(JSON.stringify(sorted));
+}
+
+/** True if the model saw less than the full prompt (chunk_sha !== prompt_sha); null if undeterminable. */
+export function attestationTruncated(att) {
+ return typeof att?.prompt_sha === "string" && typeof att?.chunk_sha === "string" ? att.chunk_sha !== att.prompt_sha : null;
+}
+
+function _fromHex(h) { const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return a; }
+function _toHex(buf) { let o = ""; for (const x of new Uint8Array(buf)) o += x.toString(16).padStart(2, "0"); return o; }
+// constant-time string compare — no length/early-exit leak (mirrors edge crypto-util.timingSafeEqual).
+function _timingSafeEqual(a, b) { const n = Math.max(a.length, b.length); let m = a.length ^ b.length; for (let i = 0; i < n; i++) m |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); return m === 0; }
+
+/**
+ * Verify a context-attestation. Returns `true`/`false` when it is signed and checkable, and `null` when it
+ * is unsigned or is an HMAC attestation given without its key. Ed25519 is self-describing (no key needed) —
+ * fully trustless + offline. Throws only if WebCrypto is unavailable (need Node >=18 or a modern browser).
+ */
+async function _attSigValid(att, hmacKey) { // crypto half: is the SIGNATURE valid? (true/false/null)
+ if (!att || !att.alg || att.alg === "none" || !att.sig) return null;
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
+ if (!subtle) throw new Error("WebCrypto unavailable — verifyAttestation needs Node >=18 or a modern browser");
+ const msg = new TextEncoder().encode(canonicalAttestation(att));
+ if (att.alg === "ed25519") {
+ if (!att.pubkey) return null;
+ try {
+ const pub = await subtle.importKey("raw", _fromHex(att.pubkey), "Ed25519", false, ["verify"]);
+ return await subtle.verify("Ed25519", pub, _fromHex(att.sig), msg); // self-describing: anyone verifies
+ } catch { return false; }
+ }
+ if (att.alg === "hmac-sha256") {
+ if (!hmacKey) return null; // we-verify only with the shared key
+ const key = await subtle.importKey("raw", new TextEncoder().encode(hmacKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
+ return _timingSafeEqual(_toHex(await subtle.sign("HMAC", key, msg)), att.sig);
+ }
+ return null;
+}
+
+/**
+ * Verify a context-attestation (signature half: {@link _attSigValid}). Pass `{registry}` to ALSO require the
+ * signer be a WAVE-published key — a valid signature from an untrusted key then returns `false`. Without
+ * `{registry}`, behaviour is byte-identical to before.
+ */
+export async function verifyAttestation(att, { hmacKey, registry } = {}) {
+ const v = await _attSigValid(att, hmacKey);
+ if (registry && v === true && (await trustedSigner(att, registry)) === false) return false;
+ return v;
+}
+
+// ── Payment-Receipt + trusted-key registry: both live in ./verify.js (self-contained, zero-dep) so this
+// entrypoint stays under the 300-line gate; re-exported so the public API is unchanged. ────────────────
+import { trustedSigner } from "./verify.js"; // local use by the {registry} fold above (imports are hoisted)
+export { canonicalPaymentReceipt, verifyPaymentReceipt, makeRegistry, trustedSigner } from "./verify.js";
+
export default Dispatch;
diff --git a/sdk/js/package.json b/sdk/js/package.json
index f09f0ef..a49d1de 100644
--- a/sdk/js/package.json
+++ b/sdk/js/package.json
@@ -1,15 +1,16 @@
{
"name": "@wave-av/dispatch",
"version": "0.7.0",
- "description": "wave Dispatch — route each request to the cheapest capable model (local-first; escalate to your frontier model only when needed). BYO keys + infra; we return the routing decision.",
+ "description": "WAVE Dispatch — route each request to the cheapest capable model (local-first; escalate to your frontier model only when needed). BYO keys + infra; we return the routing decision.",
"type": "module",
"main": "index.js",
"types": "index.d.ts",
"exports": { ".": { "types": "./index.d.ts", "default": "./index.js" } },
"bin": { "dispatch": "bin/dispatch.js" },
- "files": ["index.js", "index.d.ts", "bin/", "README.md"],
+ "files": ["index.js", "verify.js", "index.d.ts", "bin/", "README.md"],
"scripts": {
- "typecheck": "tsc -p tsconfig.json"
+ "typecheck": "tsc -p tsconfig.json",
+ "test": "node --test"
},
"keywords": ["llm", "routing", "local-first", "cost", "edge", "wave", "dispatch", "x402", "agents"],
"author": "WAVE Online, LLC",
diff --git a/sdk/js/receipt.test.js b/sdk/js/receipt.test.js
new file mode 100644
index 0000000..8041345
--- /dev/null
+++ b/sdk/js/receipt.test.js
@@ -0,0 +1,52 @@
+// Zero-dep tests (node:test) for the SDK's offline Payment-Receipt verifier — the MONEY half of "the two
+// receipts". The decisive test is CROSS-IMPLEMENTATION: EXPECTED_CANONICAL is the SAME constant pinned in
+// edge-router/payment-receipt.test.ts, so a green here proves the SDK and the edge signer produce byte-for-
+// byte identical canonical strings — "sign on the edge, verify in any SDK, trust no one" for payments.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { canonicalPaymentReceipt, verifyPaymentReceipt } from "./index.js";
+
+// Pinned identically in edge-router/payment-receipt.test.ts (the byte-parity vector).
+const FIXED = {
+ v: "wave.payment-receipt/v0", ts: 1700000000, protocol: "x402", mode: "wave-x402",
+ resource: "/extract", network: "base", asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ amount_atomic: "1000", pay_to: "0x0000000000000000000000000000000000000001",
+ tx_hash: "0xdeadbeef", verified: true,
+};
+const EXPECTED_CANONICAL =
+ '{"amount_atomic":"1000","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",' +
+ '"mode":"wave-x402","network":"base","pay_to":"0x0000000000000000000000000000000000000001",' +
+ '"protocol":"x402","resource":"/extract","ts":1700000000,"tx_hash":"0xdeadbeef",' +
+ '"v":"wave.payment-receipt/v0","verified":true}';
+
+const _toHex = (buf) => [...new Uint8Array(buf)].map((x) => x.toString(16).padStart(2, "0")).join("");
+
+test("canonical receipt string is byte-identical to the edge signer (cross-impl parity)", () => {
+ assert.equal(canonicalPaymentReceipt(FIXED), EXPECTED_CANONICAL);
+});
+
+test("Ed25519: signs, self-verifies with NO secret (trustless, offline); tamper fails", async () => {
+ const subtle = globalThis.crypto.subtle, enc = new TextEncoder();
+ const kp = await subtle.generateKey("Ed25519", true, ["sign", "verify"]);
+ const pubkey = _toHex(await subtle.exportKey("raw", kp.publicKey));
+ const sig = _toHex(await subtle.sign("Ed25519", kp.privateKey, enc.encode(canonicalPaymentReceipt(FIXED))));
+ const signed = { ...FIXED, alg: "ed25519", sig, pubkey };
+ assert.equal(await verifyPaymentReceipt(signed), true);
+ assert.equal(await verifyPaymentReceipt({ ...signed, amount_atomic: "999999" }), false); // tampered money amount
+ assert.equal(await verifyPaymentReceipt({ ...signed, verified: false }), false); // tampered settlement flag
+});
+
+test("HMAC: verifies with the key, fails with the wrong one, null without it", async () => {
+ const subtle = globalThis.crypto.subtle, enc = new TextEncoder();
+ const key = await subtle.importKey("raw", enc.encode("k"), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
+ const sig = _toHex(await subtle.sign("HMAC", key, enc.encode(canonicalPaymentReceipt(FIXED))));
+ const r = { ...FIXED, alg: "hmac-sha256", sig };
+ assert.equal(await verifyPaymentReceipt(r, { hmacKey: "k" }), true);
+ assert.equal(await verifyPaymentReceipt(r, { hmacKey: "wrong" }), false);
+ assert.equal(await verifyPaymentReceipt(r), null); // HMAC without our key -> uncheckable
+});
+
+test("unsigned / no-alg -> null (not false)", async () => {
+ assert.equal(await verifyPaymentReceipt({ ...FIXED }), null);
+ assert.equal(await verifyPaymentReceipt({ ...FIXED, alg: "none", sig: null }), null);
+});
diff --git a/sdk/js/trusted.test.js b/sdk/js/trusted.test.js
new file mode 100644
index 0000000..2c2ad5b
--- /dev/null
+++ b/sdk/js/trusted.test.js
@@ -0,0 +1,94 @@
+// trusted.test.js — the cross-implementation pin for the TRUSTED-KEY REGISTRY: trust's SECOND axis.
+// verify*() proves the SIGNATURE; trustedSigner() proves the KEY is one WAVE published (registry membership
+// + key_id honesty); passing {registry} to verify*() FOLDS the two into one verdict ("valid AND ours").
+// The FIXED vectors below are byte-identical to tests/test_sdk_python_verify.py and the Go/Ruby/Rust ports +
+// the edge — a green here proves the trust logic is the SAME in every runtime. NOTE key_id is the edge's
+// real derivation: sha256Hex(fromHex(pubkey)).slice(0,16) — the FIRST 16 hex chars, not the full digest.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { makeRegistry, trustedSigner, verifyPaymentReceipt, verifyAttestation } from "./index.js";
+
+// --- FIXED Payment-Receipt vector (identical to receipt.test.js / test_sdk_python_verify.py) ---
+const PUBKEY = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c";
+const KEY_ID = "34750f98bd59fcfc"; // = sha256Hex(fromHex(PUBKEY)).slice(0,16) — the edge's exact id
+const OTHER = "00".repeat(32); // a structurally-valid 32-byte pubkey that is NOT WAVE's
+const FIXED_RECEIPT = {
+ v: "wave.payment-receipt/v0", ts: 1700000000, protocol: "x402", mode: "wave-x402",
+ resource: "/extract", network: "base", asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ amount_atomic: "1000", pay_to: "0x0000000000000000000000000000000000000001",
+ tx_hash: "0xdeadbeef", verified: true,
+};
+const RECEIPT_SIG = "e333772435ba2b16c9b52188a489de18f0dbba870a84b6dcc6fe63ef3dffae5a" +
+ "b9e65daa85b3306d4d3df879bc47c6282f87c661e634390a9a5d93e7c502220a";
+const SIGNED = { ...FIXED_RECEIPT, alg: "ed25519", sig: RECEIPT_SIG, pubkey: PUBKEY, key_id: KEY_ID };
+
+// --- FIXED context-attestation vector (different signer key — the Python-signer key) ---
+const ATT_PUBKEY = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
+const ATT_KEY_ID = "56475aa75463474c";
+const BASE_ATT = {
+ v: "wave.context-attestation/v0", ts: 1700000000, model: "qwen3:30b", source: "café.md",
+ chunk: 2, chunk_sha: "deadbeef", chunk_chars: 2980, num_ctx: 8192, prompt_sha: "cafe1234",
+ kept: 3, dropped_hallucinated: 1,
+};
+const ATT_SIG = "68017540b4c3b060e51faab64c28f4e1ea6994d3781fec14878e73052046d78d" +
+ "05757a8287af644b245b10274ba3b60329ed216472839d7573debf4256e9730b";
+const SIGNED_ATT = { ...BASE_ATT, alg: "ed25519", sig: ATT_SIG, pubkey: ATT_PUBKEY, key_id: ATT_KEY_ID };
+
+test("makeRegistry: pubkey membership, case-insensitive, drops malformed, reads /.well-known payload", () => {
+ const reg = makeRegistry([{ pubkey: PUBKEY, key_id: KEY_ID }]);
+ assert.equal(reg.size, 1);
+ assert.equal(reg.has(PUBKEY), true);
+ assert.equal(reg.has(PUBKEY.toUpperCase()), true); // membership is case-insensitive
+ assert.equal(reg.has(OTHER), false);
+ // accepts a {keys:[...]} well-known payload AND bare-string entries; silently drops non-hex / odd-length
+ const reg2 = makeRegistry({ keys: [PUBKEY, "nothex!!", "abc"] });
+ assert.equal(reg2.size, 1);
+ assert.equal(makeRegistry(null).size, 0); // no entries -> empty (an empty registry decides nothing)
+});
+
+test("trustedSigner: tri-state — true=ours, false=valid-but-untrusted, null=cannot-decide", async () => {
+ const reg = makeRegistry([PUBKEY]);
+ assert.equal(await trustedSigner(SIGNED, reg), true); // WAVE's published key
+ assert.equal(await trustedSigner(SIGNED, makeRegistry([OTHER])), false); // valid shape, NOT trusted
+ assert.equal(await trustedSigner(SIGNED, null), null); // no registry -> cannot decide
+ assert.equal(await trustedSigner(SIGNED, makeRegistry([])), null); // empty registry -> cannot decide
+ assert.equal(await trustedSigner({ ...SIGNED, alg: "hmac-sha256" }, reg), null); // a pubkey-registry can't vouch for a shared secret
+ assert.equal(await trustedSigner({ ...FIXED_RECEIPT, alg: "none" }, reg), null); // unsigned
+});
+
+test("trustedSigner: a key_id that LIES about its pubkey -> false (even when the pubkey IS trusted)", async () => {
+ const reg = makeRegistry([PUBKEY]);
+ assert.equal(await trustedSigner({ ...SIGNED, key_id: "deadbeefdeadbeef" }, reg), false); // recompute catches the lie
+ assert.equal(await trustedSigner({ ...SIGNED, key_id: undefined }, reg), true); // absent key_id is fine (membership is by pubkey)
+});
+
+test("FOLD: verifyPaymentReceipt({registry}) = signature-valid AND key-trusted; backward-compatible without it", async () => {
+ assert.equal(await verifyPaymentReceipt(SIGNED), true); // no registry -> identical to today
+ assert.equal(await verifyPaymentReceipt(SIGNED, { registry: makeRegistry([PUBKEY]) }), true); // valid + ours
+ assert.equal(await verifyPaymentReceipt(SIGNED, { registry: makeRegistry([OTHER]) }), false); // valid sig, UNtrusted key -> reject
+ assert.equal( // a registry NEVER upgrades a bad sig
+ await verifyPaymentReceipt({ ...SIGNED, amount_atomic: "999999" }, { registry: makeRegistry([PUBKEY]) }), false);
+});
+
+test("FOLD: verifyAttestation({registry}) works on the provenance half too", async () => {
+ assert.equal(await verifyAttestation(SIGNED_ATT), true); // backward-compat
+ assert.equal(await verifyAttestation(SIGNED_ATT, { registry: makeRegistry([ATT_PUBKEY]) }), true); // valid + ours
+ assert.equal(await verifyAttestation(SIGNED_ATT, { registry: makeRegistry([OTHER]) }), false); // valid sig, untrusted key
+});
+
+test("trustedSigner: no-WebCrypto + key_id throws a DESCRIPTIVE error; a pure no-key_id lookup stays crypto-free", async () => {
+ // Sentry #381 review: a direct trustedSigner() in a runtime without WebCrypto (Node <18) must surface a clear
+ // error from the key_id self-consistency check — never an opaque `TypeError: …'subtle' of undefined`. And it
+ // must FAIL LOUD, not degrade to null (a null would let the {registry} fold silently pass an untrusted key).
+ const reg = makeRegistry([PUBKEY]);
+ const saved = Object.getOwnPropertyDescriptor(globalThis, "crypto");
+ try {
+ Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true }); // simulate Node <18
+ await assert.rejects(trustedSigner(SIGNED, reg), /WebCrypto unavailable/); // key_id forces sha256 -> clear throw
+ // a registry-membership lookup with NO key_id needs no crypto and must still decide (the guard is surgical)
+ assert.equal(await trustedSigner({ ...SIGNED, key_id: undefined }, reg), true);
+ assert.equal(await trustedSigner({ ...SIGNED, key_id: undefined }, makeRegistry([OTHER])), false);
+ } finally {
+ Object.defineProperty(globalThis, "crypto", saved); // restore for the rest of the suite
+ }
+});
diff --git a/sdk/js/verify.js b/sdk/js/verify.js
new file mode 100644
index 0000000..9a9f41b
--- /dev/null
+++ b/sdk/js/verify.js
@@ -0,0 +1,112 @@
+// verify.js — WAVE Payment-Receipt verifier: the MONEY half of "the two receipts", offline + trustless +
+// zero-dep + instance-free. Extracted to its own module (the context-attestation verifier stays in index.js)
+// so the published entrypoint stays under the 300-line gate. Re-exported from index.js so the public API is
+// unchanged: `import { verifyPaymentReceipt } from "@wave-av/dispatch"`.
+//
+// A Payment-Receipt is dispatch's signed, self-describing statement that it verified a settlement for a
+// resource — the cryptographic twin of the context-attestation. Ed25519 receipts embed their own pubkey, so
+// an agent confirms "I really paid for this, and dispatch verified the settlement" with NO secret and NO
+// network. HMAC receipts need the shared key. The canonical is byte-identical to the edge signer
+// (edge-router/payment-receipt.ts), pinned by the shared EXPECTED vector in receipt.test.js. The helper
+// copies below intentionally mirror the canonical WIRE PROTOCOL (the same scheme context-attest + the edge
+// re-implement and pin) — replicating it here keeps this verifier self-contained.
+
+function _asciiEscape(s) {
+ // Python's json.dumps(ensure_ascii=True) escapes every non-ASCII char as \uXXXX; JSON.stringify does not.
+ return s.replace(/[-]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
+}
+function _fromHex(h) { const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16); return a; }
+function _toHex(buf) { let o = ""; for (const x of new Uint8Array(buf)) o += x.toString(16).padStart(2, "0"); return o; }
+// constant-time string compare — no length/early-exit leak (mirrors edge crypto-util.timingSafeEqual).
+function _timingSafeEqual(a, b) { const n = Math.max(a.length, b.length); let m = a.length ^ b.length; for (let i = 0; i < n; i++) m |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0); return m === 0; }
+
+const RECEIPT_FIELDS = ["v", "ts", "protocol", "mode", "resource", "network", "asset", "amount_atomic", "pay_to", "tx_hash", "verified"];
+
+/** Canonical signing string for a Payment-Receipt — byte-for-byte identical to the edge signer. */
+export function canonicalPaymentReceipt(r) {
+ const sorted = {};
+ for (const k of [...RECEIPT_FIELDS].sort()) sorted[k] = r?.[k] ?? null; // missing -> null, like Python None
+ return _asciiEscape(JSON.stringify(sorted));
+}
+
+/**
+ * Verify a Payment-Receipt. Tri-state, mirroring verifyAttestation: `true`/`false` when signed & checkable,
+ * `null` when unsigned or an HMAC receipt given without its key. Ed25519 is self-describing (fully trustless,
+ * offline). Throws only if WebCrypto is unavailable (Node >=18 or a modern browser).
+ */
+async function _receiptSigValid(r, hmacKey) { // crypto half: is the SIGNATURE valid? (true/false/null)
+ if (!r || !r.alg || r.alg === "none" || !r.sig) return null;
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
+ if (!subtle) throw new Error("WebCrypto unavailable — verifyPaymentReceipt needs Node >=18 or a modern browser");
+ const msg = new TextEncoder().encode(canonicalPaymentReceipt(r));
+ if (r.alg === "ed25519") {
+ if (!r.pubkey) return null;
+ try {
+ const pub = await subtle.importKey("raw", _fromHex(r.pubkey), "Ed25519", false, ["verify"]);
+ return await subtle.verify("Ed25519", pub, _fromHex(r.sig), msg); // self-describing: anyone verifies
+ } catch { return false; }
+ }
+ if (r.alg === "hmac-sha256") {
+ if (!hmacKey) return null;
+ const key = await subtle.importKey("raw", new TextEncoder().encode(hmacKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
+ return _timingSafeEqual(_toHex(await subtle.sign("HMAC", key, msg)), r.sig);
+ }
+ return null;
+}
+
+// ── trusted-key registry: trust's SECOND axis ("is the signer WAVE's key?", not just "is the sig valid?") ──
+// A receipt/attestation embeds its own Ed25519 pubkey, so verify*() proves the SIGNATURE — but ANY keypair
+// makes a valid signature. trustedSigner answers the orthogonal question: is this pubkey one WAVE PUBLISHED?
+// Pure sha256 + set lookup (NO signature check — compose with verify*(), or pass {registry} to verify*() for
+// the conjunction). Tri-state like verify*(): null when it cannot decide (no/empty registry, unsigned, alg
+// "none", or HMAC — a PUBLIC-key registry can never vouch for a shared secret).
+async function _sha256Hex(bytes) {
+ // Only reached from trustedSigner's key_id self-consistency check (so a registry lookup with NO key_id stays
+ // crypto-free). Mirror _receiptSigValid: fail LOUD on a missing WebCrypto runtime rather than throwing an
+ // opaque `TypeError` — and never degrade to null, which would let the {registry} fold silently pass an
+ // untrusted key (the fold only rejects on === false).
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
+ if (!subtle) throw new Error("WebCrypto unavailable — trustedSigner key_id check needs Node >=18 or a modern browser");
+ return _toHex(await subtle.digest("SHA-256", bytes));
+}
+function _isHex(s) { return typeof s === "string" && s.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(s); }
+
+/**
+ * Build a trusted-key registry from entries — `{pubkey, key_id?}` objects (or bare pubkey-hex strings), or a
+ * `/.well-known/wave-keys.json` payload `{keys:[...]}`. Returns `{ has(pubkeyHex), size }`: a normalized set
+ * of lowercase-hex pubkeys. Malformed (non-hex / odd-length) entries are silently dropped.
+ */
+export function makeRegistry(entries) {
+ const list = Array.isArray(entries) ? entries : (entries && Array.isArray(entries.keys) ? entries.keys : []);
+ const set = new Set();
+ for (const e of list) {
+ const pub = typeof e === "string" ? e : (e && e.pubkey);
+ if (_isHex(pub) && pub.length) set.add(pub.toLowerCase());
+ }
+ return { has: (p) => typeof p === "string" && set.has(p.toLowerCase()), size: set.size };
+}
+
+/**
+ * Is the signer's key one WAVE published? `true` = ed25519 key present in `registry` AND its stated key_id
+ * (if any) hashes from its pubkey. `false` = a valid-shaped ed25519 key NOT in the registry, or a key_id
+ * that LIES about its pubkey. `null` = cannot decide (no/empty registry, unsigned, alg "none", HMAC). Does
+ * NOT verify the signature — that's verify*(). key_id mirrors the edge: sha256Hex(fromHex(pubkey)).slice(0,16).
+ */
+export async function trustedSigner(r, registry) {
+ if (!registry || !registry.size || !r || r.alg !== "ed25519" || !r.pubkey) return null;
+ const pub = String(r.pubkey).toLowerCase();
+ if (!_isHex(pub)) return false;
+ if (r.key_id && String(r.key_id).toLowerCase() !== (await _sha256Hex(_fromHex(pub))).slice(0, 16)) return false;
+ return registry.has(pub);
+}
+
+/**
+ * Verify a Payment-Receipt. Tri-state (true/false/null), offline + trustless. Pass `{registry}` to FOLD in
+ * the trusted-key check: a cryptographically-valid receipt whose key is NOT in `registry` returns `false`
+ * (a registry NEVER upgrades an invalid signature). Without `{registry}`, behaviour is byte-identical to before.
+ */
+export async function verifyPaymentReceipt(r, { hmacKey, registry } = {}) {
+ const v = await _receiptSigValid(r, hmacKey);
+ if (registry && v === true && (await trustedSigner(r, registry)) === false) return false;
+ return v;
+}
diff --git a/sdk/python/README.md b/sdk/python/README.md
index 918e165..214df1a 100644
--- a/sdk/python/README.md
+++ b/sdk/python/README.md
@@ -1,6 +1,6 @@
# wave-dispatch (Python)
-Thin client for [wave Dispatch](https://dispatch.wave.online) — route each request to the cheapest
+Thin client for [WAVE Dispatch](https://dispatch.wave.online) — route each request to the cheapest
capable model (local-first; escalate to your frontier only when needed). BYO keys + infra. Zero deps.
```python
diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml
index f3de3d4..42d0bce 100644
--- a/sdk/python/pyproject.toml
+++ b/sdk/python/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "wave-dispatch"
version = "0.7.0"
-description = "wave Dispatch — local-first AI router. `dispatch serve` runs an OpenAI-compatible proxy that routes each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra."
+description = "WAVE Dispatch — local-first AI router. `dispatch serve` runs an OpenAI-compatible proxy that routes each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra."
readme = "README.md"
requires-python = ">=3.8"
license = { text = "MIT" }
diff --git a/sdk/python/wave_dispatch/__init__.py b/sdk/python/wave_dispatch/__init__.py
index 04f1803..40b9a18 100644
--- a/sdk/python/wave_dispatch/__init__.py
+++ b/sdk/python/wave_dispatch/__init__.py
@@ -1,4 +1,4 @@
-"""wave Dispatch — thin Python client. Route each request to the cheapest capable model (local-first;
+"""WAVE Dispatch — thin Python client. Route each request to the cheapest capable model (local-first;
escalate to your frontier only when needed). BYO keys + infra; the service returns a routing decision.
Stdlib-only (urllib) — zero dependencies."""
import base64
@@ -8,6 +8,13 @@
import urllib.request
import urllib.error
from typing import Callable, Optional, Dict, Any # CR/#3: py3.8 compat (str | None is py3.10+)
+# E4 verify-everywhere: offline trustless verifiers for the two receipts (canonical byte-parity to the edge
+# signers + the JS SDK, pinned by shared vectors). Stdlib-only at import; Ed25519 lazily needs `cryptography`.
+from .verify import ( # noqa: F401
+ canonical_payment_receipt, verify_payment_receipt,
+ canonical_attestation, verify_attestation, attestation_truncated,
+ make_registry, trusted_signer,
+)
__version__ = "0.6.2"
DEFAULT_ENDPOINT = "https://dispatch.wave.online"
@@ -29,7 +36,7 @@ def _with_profile(body: dict, profile: Optional[str]) -> dict:
class Dispatch:
- """Client for the wave Dispatch edge API."""
+ """Client for the WAVE Dispatch edge API."""
def __init__(self, license: Optional[str] = None, endpoint: str = DEFAULT_ENDPOINT,
agents_endpoint: Optional[str] = None,
diff --git a/sdk/python/wave_dispatch/cli.py b/sdk/python/wave_dispatch/cli.py
index 60c58aa..e09349b 100644
--- a/sdk/python/wave_dispatch/cli.py
+++ b/sdk/python/wave_dispatch/cli.py
@@ -1,4 +1,4 @@
-"""wave Dispatch CLI — `dispatch serve` runs the local-first proxy. Installed by `pip install wave-dispatch`."""
+"""WAVE Dispatch CLI — `dispatch serve` runs the local-first proxy. Installed by `pip install wave-dispatch`."""
import sys
@@ -9,7 +9,7 @@ def main():
proxy.serve()
return
print(
- "wave Dispatch — https://dispatch.wave.online\n"
+ "WAVE Dispatch — https://dispatch.wave.online\n"
"\n"
" dispatch serve run the local-first OpenAI-compatible proxy on :8090\n"
" point your agent at it: OPENAI_BASE_URL=http://localhost:8090/v1\n"
diff --git a/sdk/python/wave_dispatch/proxy.py b/sdk/python/wave_dispatch/proxy.py
index 84c9f08..fe32acc 100644
--- a/sdk/python/wave_dispatch/proxy.py
+++ b/sdk/python/wave_dispatch/proxy.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""wave Dispatch — local-first proxy (`dispatch serve`). Speaks the OpenAI /v1/chat/completions wire
+"""WAVE Dispatch — local-first proxy (`dispatch serve`). Speaks the OpenAI /v1/chat/completions wire
format, so ANY agent/SDK with a custom base URL routes through it (Codex, Cursor, Continue, aider, …).
Stdlib only — no extra deps.
@@ -157,7 +157,7 @@ def do_POST(self):
def serve():
mode = "hosted classifier (licensed)" if LICENSE else "local heuristic (no license — trivial only)"
- print(f"wave Dispatch proxy :{PORT} decision={mode}")
+ print(f"WAVE Dispatch proxy :{PORT} decision={mode}")
print(f" upstream={UPSTREAM} local={OLLAMA} ({LOCAL_MODEL})")
print(f" point your agent: OPENAI_BASE_URL=http://localhost:{PORT}/v1")
ThreadingHTTPServer(("127.0.0.1", PORT), _Handler).serve_forever()
diff --git a/sdk/python/wave_dispatch/verify.py b/sdk/python/wave_dispatch/verify.py
new file mode 100644
index 0000000..706f7f4
--- /dev/null
+++ b/sdk/python/wave_dispatch/verify.py
@@ -0,0 +1,139 @@
+"""verify.py — offline, trustless verifiers for the TWO RECEIPTS in the Python SDK (E4 verify-everywhere).
+
+Mirrors sdk/js (verifyPaymentReceipt + verifyAttestation): a Payment-Receipt is the MONEY proof ("I paid,
+dispatch verified the settlement"), a context-attestation the PROVENANCE proof ("what the model saw"). An
+Ed25519 record embeds its own pubkey, so anyone verifies it with NO secret and NO network; HMAC needs the
+shared key. The canonical signing string is byte-for-byte identical to the edge signers
+(edge-router/payment-receipt.ts, context_integrity) — pinned by the shared vectors in
+tests/test_sdk_python_verify.py (the same EXPECTED constants as the JS SDK). Canonical + HMAC + tri-state are
+stdlib-only; Ed25519 lazily imports `cryptography` (the optional `cdp` extra) and raises a clear error if it
+is absent (mirrors the JS verifier's "WebCrypto unavailable")."""
+import hmac as _hmac
+import hashlib as _hashlib
+import json as _json
+from typing import Any, Dict, Optional
+
+# Field order is irrelevant (we sort) but the SET is the contract — identical to the edge signers + JS SDK.
+RECEIPT_FIELDS = ["v", "ts", "protocol", "mode", "resource", "network", "asset",
+ "amount_atomic", "pay_to", "tx_hash", "verified"]
+ATTEST_FIELDS = ["v", "ts", "model", "source", "chunk", "chunk_sha", "chunk_chars",
+ "num_ctx", "prompt_sha", "kept", "dropped_hallucinated"]
+
+
+def _canonical(obj: Optional[Dict[str, Any]], fields) -> str:
+ """asciiEscape(JSON.stringify(sorted-fields-with-nulls)). Python's json.dumps with
+ separators=(",", ":") + ensure_ascii=True is byte-identical to the JS scheme: compact (no spaces),
+ sorted keys, every non-ASCII char as \\uXXXX. A missing field -> None -> null, exactly like JS `?? null`."""
+ sorted_obj = {k: ((obj or {}).get(k)) for k in sorted(fields)}
+ return _json.dumps(sorted_obj, separators=(",", ":"), ensure_ascii=True)
+
+
+def canonical_payment_receipt(r: Dict[str, Any]) -> str:
+ """Canonical signing string for a Payment-Receipt — byte-identical to the edge signer."""
+ return _canonical(r, RECEIPT_FIELDS)
+
+
+def canonical_attestation(att: Dict[str, Any]) -> str:
+ """Canonical signing string for a context-attestation — byte-identical to the Python/edge signer."""
+ return _canonical(att, ATTEST_FIELDS)
+
+
+def _verify_ed25519(pubkey_hex: str, sig_hex: str, msg: bytes) -> bool:
+ try:
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+ from cryptography.exceptions import InvalidSignature
+ except ImportError as e: # be explicit (like JS's WebCrypto error) — never silently treat as valid/invalid
+ raise RuntimeError(
+ "Ed25519 verify needs the `cryptography` package — pip install 'wave-dispatch[cdp]'"
+ ) from e
+ try:
+ Ed25519PublicKey.from_public_bytes(bytes.fromhex(pubkey_hex)).verify(bytes.fromhex(sig_hex), msg)
+ return True
+ except InvalidSignature:
+ return False
+ except Exception:
+ return False # malformed pubkey/sig hex -> not a valid signature
+
+
+def _verify(obj: Optional[Dict[str, Any]], canonical_fn, hmac_key: Optional[str]) -> Optional[bool]:
+ if not obj or not obj.get("alg") or obj.get("alg") == "none" or not obj.get("sig"):
+ return None # unsigned / no-alg -> None (NOT False), mirroring the JS tri-state
+ msg = canonical_fn(obj).encode("utf-8")
+ alg = obj["alg"]
+ if alg == "ed25519":
+ pubkey = obj.get("pubkey")
+ if not pubkey:
+ return None # self-describing record missing its pubkey -> uncheckable
+ return _verify_ed25519(pubkey, obj["sig"], msg)
+ if alg == "hmac-sha256":
+ if not hmac_key:
+ return None # HMAC without the shared key -> uncheckable
+ want = _hmac.new(hmac_key.encode("utf-8"), msg, _hashlib.sha256).hexdigest()
+ return _hmac.compare_digest(want, str(obj["sig"])) # constant-time
+ return None
+
+
+def _is_hex(s: Any) -> bool:
+ return isinstance(s, str) and len(s) % 2 == 0 and all(c in "0123456789abcdefABCDEF" for c in s)
+
+
+def make_registry(entries: Any) -> frozenset:
+ """Build a trusted-key registry — a frozenset of lowercase-hex pubkeys — from a list of {pubkey, key_id?}
+ dicts (or bare pubkey-hex strings), or a /.well-known/wave-keys.json payload {"keys": [...]}. Malformed
+ (non-hex / odd-length) entries are dropped. Membership test: `pubkey_hex.lower() in registry`."""
+ if isinstance(entries, dict):
+ entries = entries.get("keys") or []
+ out = set()
+ for e in (entries or []):
+ pub = e if isinstance(e, str) else (e.get("pubkey") if isinstance(e, dict) else None)
+ if pub and _is_hex(pub):
+ out.add(pub.lower())
+ return frozenset(out)
+
+
+def trusted_signer(obj: Optional[Dict[str, Any]], registry: Any) -> Optional[bool]:
+ """Is the signer's key one WAVE published? Tri-state. True = ed25519 key in `registry` with an honest
+ key_id; False = a valid-shaped key NOT in the registry, or a key_id that LIES about its pubkey; None =
+ cannot decide (no/empty registry, unsigned, "none", or HMAC — a pubkey registry can't vouch for a shared
+ secret). Does NOT verify the signature. key_id mirrors the edge: sha256(pubkey)[:16]."""
+ if not registry or not obj or obj.get("alg") != "ed25519" or not obj.get("pubkey"):
+ return None
+ pub = str(obj["pubkey"]).lower()
+ if not _is_hex(pub):
+ return False
+ kid = obj.get("key_id")
+ if kid and str(kid).lower() != _hashlib.sha256(bytes.fromhex(pub)).hexdigest()[:16]:
+ return False
+ return pub in registry
+
+
+def verify_payment_receipt(r: Dict[str, Any], hmac_key: Optional[str] = None,
+ registry: Any = None) -> Optional[bool]:
+ """Tri-state: True/False when signed & checkable, None when unsigned or an HMAC receipt without its key.
+ Ed25519 is self-describing -> fully offline & trustless (no secret, no network). Pass `registry` (see
+ make_registry) to FOLD in the trusted-key check: a valid signature from an untrusted key returns False (a
+ registry never upgrades a bad sig). Omit it -> behaviour is identical to before."""
+ v = _verify(r, canonical_payment_receipt, hmac_key)
+ if registry and v is True and trusted_signer(r, registry) is False:
+ return False
+ return v
+
+
+def verify_attestation(att: Dict[str, Any], hmac_key: Optional[str] = None,
+ registry: Any = None) -> Optional[bool]:
+ """Tri-state verifier for a context-attestation. `registry` folds in the trusted-key check (see
+ verify_payment_receipt)."""
+ v = _verify(att, canonical_attestation, hmac_key)
+ if registry and v is True and trusted_signer(att, registry) is False:
+ return False
+ return v
+
+
+def attestation_truncated(att: Dict[str, Any]) -> Optional[bool]:
+ """True/False/None: chunk_sha != prompt_sha proves the model saw less than the full input; None when the
+ second hash is absent (truncation indeterminable)."""
+ a = att or {}
+ chunk_sha, prompt_sha = a.get("chunk_sha"), a.get("prompt_sha")
+ if not chunk_sha or not prompt_sha:
+ return None
+ return chunk_sha != prompt_sha
diff --git a/sdk/ruby/lib/wave_dispatch.rb b/sdk/ruby/lib/wave_dispatch.rb
index efeca66..34e2fcf 100644
--- a/sdk/ruby/lib/wave_dispatch.rb
+++ b/sdk/ruby/lib/wave_dispatch.rb
@@ -1,4 +1,4 @@
-# wave Dispatch — thin Ruby client. Route each request to the cheapest capable model (local-first;
+# WAVE Dispatch — thin Ruby client. Route each request to the cheapest capable model (local-first;
# escalate to your frontier only when needed). BYO keys + infra. Stdlib only (net/http).
require "base64"
require "net/http"
@@ -6,6 +6,12 @@
require "uri"
require "openssl"
+# Offline verifiers for "the two receipts" (Payment-Receipt + context-Attestation): self-contained
+# (json + openssl only) so it loads independently of the client above. Exposes the flat module API
+# WaveDispatch.canonical_payment_receipt / .verify_payment_receipt / .canonical_attestation /
+# .verify_attestation / .attestation_truncated. See lib/wave_dispatch/verify.rb.
+require_relative "wave_dispatch/verify"
+
module WaveDispatch
VERSION = "0.7.0"
diff --git a/sdk/ruby/lib/wave_dispatch/verify.rb b/sdk/ruby/lib/wave_dispatch/verify.rb
new file mode 100644
index 0000000..e28a9cb
--- /dev/null
+++ b/sdk/ruby/lib/wave_dispatch/verify.rb
@@ -0,0 +1,203 @@
+# WAVE Dispatch — offline verifiers for "the two receipts": the Payment-Receipt (the MONEY half) and the
+# context-Attestation (the CONTEXT half). Both are dispatch's signed, self-describing statements — a
+# Payment-Receipt says "I paid for this and dispatch verified the settlement"; an Attestation says "this is
+# exactly what the model saw, and whether the prompt was truncated". Verifying them is offline, trustless,
+# instance-free, and stdlib-only (json + openssl): an Ed25519 record embeds its own public key, so anyone
+# confirms it with NO secret and NO network. HMAC records need the shared key.
+#
+# The canonical signing string is BYTE-IDENTICAL across every implementation (JS, Python, Go, Rust, the edge
+# signer, and this one), pinned by the shared cross-language vectors in test/verify_test.rb. That byte-parity
+# is the whole point: "sign on the edge / in the runtime, verify in any SDK, trust no one".
+#
+# This file is intentionally self-contained (only json + openssl) and re-opens the WaveDispatch module, so it
+# loads on its own — the published entrypoint (wave_dispatch.rb) just `require_relative`s it to expose the flat
+# API. Ed25519 verify needs OpenSSL >= 1.1.1 (OpenSSL::PKey.new_raw_public_key); HMAC + canonical are universal.
+# Plain `def...end` only (no endless defs) so it parses on the gemspec's declared floor (Ruby >= 2.7).
+require "json"
+require "openssl"
+
+module WaveDispatch
+ module Verify
+ # The exact field set + order semantics shared by every signer. canonical() takes ONLY these fields,
+ # sorts the NAMES alphabetically, inserts nil for any that are missing, then serializes compactly with
+ # every non-ASCII char escaped to \uXXXX (Python's json.dumps(ensure_ascii=True) shape).
+ RECEIPT_FIELDS = %w[v ts protocol mode resource network asset amount_atomic pay_to tx_hash verified].freeze
+ ATTEST_FIELDS = %w[v ts model source chunk chunk_sha chunk_chars num_ctx prompt_sha kept dropped_hallucinated].freeze
+
+ module_function
+
+ # Compact, ASCII-escaped JSON over exactly `fields` (sorted; missing => nil). Ruby Hash preserves insertion
+ # order, so inserting keys in sorted order yields sorted output. JSON.generate(ascii_only: true) is compact
+ # AND escapes every char >= U+0080 as \uXXXX (astral chars as a UTF-16 surrogate pair) — exactly the wire
+ # shape — and, unlike a browser JSON.stringify, it does NOT HTML-escape <>& (which we also must not).
+ def canonical(record, fields)
+ record = record.is_a?(Hash) ? record : {}
+ sorted = {}
+ fields.sort.each { |k| sorted[k] = record.key?(k) ? record[k] : nil }
+ JSON.generate(sorted, ascii_only: true)
+ end
+
+ def canonical_payment_receipt(receipt)
+ canonical(receipt, RECEIPT_FIELDS)
+ end
+
+ def canonical_attestation(att)
+ canonical(att, ATTEST_FIELDS)
+ end
+
+ # Tri-state verify shared by both records. Returns true/false when signed & checkable, nil when the record
+ # is unsigned or is an HMAC record handed over without its key (uncheckable, NOT a cryptographic failure):
+ # nil — not a Hash; no "alg"; alg == "none"; no "sig"; ed25519 w/o "pubkey"; hmac w/o the key; unknown alg
+ # true/false — a real cryptographic verdict
+ def verify(record, canonical_str, hmac_key)
+ return nil unless record.is_a?(Hash)
+ alg = record["alg"]
+ sig = record["sig"]
+ return nil if alg.nil? || alg == "none" || sig.nil?
+
+ case alg
+ when "ed25519"
+ pubkey = record["pubkey"]
+ return nil if pubkey.nil?
+ verify_ed25519(pubkey, sig, canonical_str)
+ when "hmac-sha256"
+ return nil if hmac_key.nil?
+ expected = OpenSSL::HMAC.hexdigest("SHA256", hmac_key, canonical_str)
+ secure_compare(expected, sig.to_s)
+ end # any other alg falls through to nil
+ end
+
+ # Ed25519 verify from the 32 raw public-key bytes (hex) over the canonical bytes; the signature is the raw
+ # 64 bytes (hex). Ruby's OpenSSL exposes raw-key Ed25519 on OpenSSL >= 1.1.1; verify() takes a nil digest
+ # for Ed25519. Any malformed key/sig (bad hex length, wrong size, unsupported OpenSSL) is a hard false —
+ # never an exception, never nil (nil is reserved for "no pubkey supplied").
+ def verify_ed25519(pubkey_hex, sig_hex, canonical_str)
+ raw_pub = [pubkey_hex.to_s].pack("H*")
+ sig_bytes = [sig_hex.to_s].pack("H*")
+ return false unless raw_pub.bytesize == 32 && sig_bytes.bytesize == 64
+ key = OpenSSL::PKey.new_raw_public_key("ED25519", raw_pub)
+ key.verify(nil, sig_bytes, canonical_str)
+ rescue StandardError
+ false
+ end
+
+ # Constant-time string compare — no length/early-exit leak (mirrors the edge crypto-util.timingSafeEqual).
+ # Implemented in pure Ruby so it works on every Ruby/OpenSSL build (OpenSSL.secure_compare and
+ # Rack::Utils.secure_compare are not universally present — e.g. a LibreSSL-linked stdlib lacks the former).
+ def secure_compare(a, b)
+ a = a.to_s.b
+ b = b.to_s.b
+ n = [a.bytesize, b.bytesize].max
+ mismatch = a.bytesize ^ b.bytesize
+ n.times { |i| mismatch |= (a.getbyte(i) || 0) ^ (b.getbyte(i) || 0) }
+ mismatch.zero?
+ end
+
+ # E4.followup — trusted-key registry: trust's SECOND axis ("is the signer one WAVE published?"). hex?,
+ # make_registry, trusted_signer mirror sdk/js/verify.js byte-for-byte (pinned by the shared FIXED vector).
+ def hex?(s)
+ s.is_a?(String) && s.length.even? && s.match?(/\A[0-9a-fA-F]*\z/)
+ end
+
+ # Build a trusted-key registry (a Hash used as a SET of lowercase-hex pubkeys) from a list of
+ # {pubkey, key_id?} hashes (or bare pubkey-hex strings), or a /.well-known/wave-keys.json payload
+ # {"keys" => [...]}. Malformed (non-hex / odd-length) entries are dropped. Membership: registry.key?(pub).
+ def make_registry(entries)
+ entries = entries["keys"] if entries.is_a?(Hash)
+ set = {}
+ Array(entries).each do |e|
+ pub = e.is_a?(String) ? e : (e.is_a?(Hash) ? e["pubkey"] : nil)
+ set[pub.downcase] = true if pub.is_a?(String) && !pub.empty? && hex?(pub)
+ end
+ set
+ end
+
+ # key_id mirrors the edge derivation: sha256(pubkey-bytes)[0, 16] hex.
+ def key_id_of(pubkey_hex)
+ OpenSSL::Digest.hexdigest("SHA256", [pubkey_hex].pack("H*"))[0, 16]
+ end
+
+ # Is the signer's key one WAVE published? Tri-state. true = ed25519 key in `registry` with an honest
+ # key_id; false = a valid-shaped key NOT in the registry, or a key_id that LIES about its pubkey; nil =
+ # cannot decide (no/empty registry, unsigned, "none", HMAC). Does NOT verify the signature.
+ def trusted_signer(record, registry)
+ return nil unless registry.is_a?(Hash) && !registry.empty? && record.is_a?(Hash)
+ return nil unless record["alg"] == "ed25519"
+
+ pub = record["pubkey"]
+ return nil if pub.nil? || pub.to_s.empty?
+
+ pub = pub.to_s.downcase
+ return false unless hex?(pub)
+
+ kid = record["key_id"]
+ return false if kid && !kid.to_s.empty? && kid.to_s.downcase != key_id_of(pub)
+
+ registry.key?(pub)
+ end
+
+ # Public: verify a Payment-Receipt. `hmac_key:` only for HMAC receipts (Ed25519 is self-describing). Pass
+ # `registry:` (see make_registry) to FOLD in the trusted-key check: a valid signature from an untrusted key
+ # returns false (a registry never upgrades a bad sig). Omit it -> behaviour is identical to before.
+ def verify_payment_receipt(receipt, hmac_key: nil, registry: nil)
+ v = verify(receipt, canonical_payment_receipt(receipt), hmac_key)
+ return false if registry && v == true && trusted_signer(receipt, registry) == false
+
+ v
+ end
+
+ # Public: verify a context-Attestation. `registry:` folds in the trusted-key check (see verify_payment_receipt).
+ def verify_attestation(att, hmac_key: nil, registry: nil)
+ v = verify(att, canonical_attestation(att), hmac_key)
+ return false if registry && v == true && trusted_signer(att, registry) == false
+
+ v
+ end
+
+ # Public: did the model see less than the full prompt? chunk_sha vs prompt_sha — both present & differ =>
+ # true; equal => false; either missing => nil (undeterminable). Pure data, no crypto.
+ def attestation_truncated(att)
+ return nil unless att.is_a?(Hash)
+ c = att["chunk_sha"]
+ p = att["prompt_sha"]
+ return nil if c.nil? || p.nil?
+ c != p
+ end
+ end
+
+ # Re-export at the module top level so the public API is flat:
+ # WaveDispatch.canonical_payment_receipt(r) / .verify_payment_receipt(r, hmac_key:) /
+ # WaveDispatch.canonical_attestation(a) / .verify_attestation(a, hmac_key:) / .attestation_truncated(a)
+ # Explicit delegators (not metaprogrammed) so keyword forwarding is correct on every Ruby — a `**kwargs`
+ # splat-forward mis-separates a trailing positional Hash on Ruby 2.6/2.7, and `def self.x = ...` endless
+ # defs would break the gemspec's declared floor (>= 2.7).
+ class << self
+ def canonical_payment_receipt(receipt)
+ Verify.canonical_payment_receipt(receipt)
+ end
+
+ def canonical_attestation(att)
+ Verify.canonical_attestation(att)
+ end
+
+ def verify_payment_receipt(receipt, hmac_key: nil, registry: nil)
+ Verify.verify_payment_receipt(receipt, hmac_key: hmac_key, registry: registry)
+ end
+
+ def verify_attestation(att, hmac_key: nil, registry: nil)
+ Verify.verify_attestation(att, hmac_key: hmac_key, registry: registry)
+ end
+
+ def make_registry(entries)
+ Verify.make_registry(entries)
+ end
+
+ def trusted_signer(record, registry)
+ Verify.trusted_signer(record, registry)
+ end
+
+ def attestation_truncated(att)
+ Verify.attestation_truncated(att)
+ end
+ end
+end
diff --git a/sdk/ruby/test/verify_test.rb b/sdk/ruby/test/verify_test.rb
new file mode 100644
index 0000000..c182cda
--- /dev/null
+++ b/sdk/ruby/test/verify_test.rb
@@ -0,0 +1,155 @@
+# Zero-dep tests (minitest, stdlib-bundled) for the SDK's offline verifiers — "the two receipts". The
+# decisive assertions are CROSS-IMPLEMENTATION: every EXPECTED_* constant below is the SAME byte-exact vector
+# pinned in the JS (receipt.test.js / attest.test.js), the edge signer, and the other SDKs. Green here proves
+# this Ruby SDK produces a byte-for-byte identical canonical string and verifies the very signatures the
+# Python signer emitted — "sign on the edge / in the runtime, verify in any SDK, trust no one".
+#
+# Ed25519 verify needs OpenSSL >= 1.1.1 (OpenSSL::PKey.new_raw_public_key). On a host whose Ruby is linked
+# against LibreSSL or an older OpenSSL, that method is absent and the Ed25519-specific cases SKIP (the canonical
+# pin, HMAC, and tri-state cases — all pure json/openssl-HMAC — always run). See ED25519_AVAILABLE below.
+require "minitest/autorun"
+require_relative "../lib/wave_dispatch/verify"
+
+ED25519_AVAILABLE = OpenSSL::PKey.respond_to?(:new_raw_public_key)
+
+class VerifyTest < Minitest::Test
+ # ── Payment-Receipt (the MONEY half) ──────────────────────────────────────────────────────────────
+ RECEIPT = {
+ "v" => "wave.payment-receipt/v0", "ts" => 1700000000, "protocol" => "x402", "mode" => "wave-x402",
+ "resource" => "/extract", "network" => "base", "asset" => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
+ "amount_atomic" => "1000", "pay_to" => "0x0000000000000000000000000000000000000001",
+ "tx_hash" => "0xdeadbeef", "verified" => true
+ }.freeze
+ EXPECTED_RECEIPT_CANONICAL =
+ '{"amount_atomic":"1000","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",' \
+ '"mode":"wave-x402","network":"base","pay_to":"0x0000000000000000000000000000000000000001",' \
+ '"protocol":"x402","resource":"/extract","ts":1700000000,"tx_hash":"0xdeadbeef",' \
+ '"v":"wave.payment-receipt/v0","verified":true}'
+ RECEIPT_PUBKEY = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c"
+ RECEIPT_SIG =
+ "e333772435ba2b16c9b52188a489de18f0dbba870a84b6dcc6fe63ef3dffae5a" \
+ "b9e65daa85b3306d4d3df879bc47c6282f87c661e634390a9a5d93e7c502220a"
+
+ # ── context-Attestation (the CONTEXT half) ────────────────────────────────────────────────────────
+ ATTEST = {
+ "v" => "wave.context-attestation/v0", "ts" => 1700000000, "model" => "qwen3:30b", "source" => "café.md",
+ "chunk" => 2, "chunk_sha" => "deadbeef", "chunk_chars" => 2980, "num_ctx" => 8192,
+ "prompt_sha" => "cafe1234", "kept" => 3, "dropped_hallucinated" => 1
+ }.freeze
+ # The café -> café ascii-escape pin: byte-exact with the Python signer (ensure_ascii=True).
+ EXPECTED_ATTEST_CANONICAL =
+ '{"chunk":2,"chunk_chars":2980,"chunk_sha":"deadbeef","dropped_hallucinated":1,"kept":3,' \
+ '"model":"qwen3:30b","num_ctx":8192,"prompt_sha":"cafe1234","source":"caf\\u00e9.md",' \
+ '"ts":1700000000,"v":"wave.context-attestation/v0"}'
+ ATTEST_PUBKEY = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"
+ ATTEST_SIG =
+ "68017540b4c3b060e51faab64c28f4e1ea6994d3781fec14878e73052046d78d" \
+ "05757a8287af644b245b10274ba3b60329ed216472839d7573debf4256e9730b"
+
+ # ── canonical byte-parity (always runs — pure json) ───────────────────────────────────────────────
+ def test_receipt_canonical_is_byte_identical_to_edge_signer
+ assert_equal EXPECTED_RECEIPT_CANONICAL, WaveDispatch.canonical_payment_receipt(RECEIPT)
+ end
+
+ def test_attestation_canonical_matches_python_signer_cafe_escape
+ assert_equal EXPECTED_ATTEST_CANONICAL, WaveDispatch.canonical_attestation(ATTEST)
+ end
+
+ # ── Ed25519: trustless + offline, self-describing (gated on OpenSSL capability) ────────────────────
+ def test_receipt_ed25519_verifies_with_no_secret_and_tamper_fails
+ skip "Ed25519 unavailable (need OpenSSL >= 1.1.1; this Ruby links #{OpenSSL::OPENSSL_VERSION})" unless ED25519_AVAILABLE
+ signed = RECEIPT.merge("alg" => "ed25519", "sig" => RECEIPT_SIG, "pubkey" => RECEIPT_PUBKEY)
+ assert_equal true, WaveDispatch.verify_payment_receipt(signed)
+ assert_equal false, WaveDispatch.verify_payment_receipt(signed.merge("amount_atomic" => "999999")) # tampered money
+ assert_equal false, WaveDispatch.verify_payment_receipt(signed.merge("verified" => false)) # tampered flag
+ end
+
+ def test_attestation_ed25519_verifies_python_signature_and_tamper_fails
+ skip "Ed25519 unavailable (need OpenSSL >= 1.1.1; this Ruby links #{OpenSSL::OPENSSL_VERSION})" unless ED25519_AVAILABLE
+ signed = ATTEST.merge("alg" => "ed25519", "sig" => ATTEST_SIG, "pubkey" => ATTEST_PUBKEY)
+ assert_equal true, WaveDispatch.verify_attestation(signed)
+ assert_equal false, WaveDispatch.verify_attestation(signed.merge("kept" => 999)) # tampered field
+ end
+
+ def test_ed25519_without_pubkey_is_nil
+ skip "Ed25519 unavailable" unless ED25519_AVAILABLE
+ assert_nil WaveDispatch.verify_payment_receipt(RECEIPT.merge("alg" => "ed25519", "sig" => RECEIPT_SIG))
+ assert_nil WaveDispatch.verify_attestation(ATTEST.merge("alg" => "ed25519", "sig" => ATTEST_SIG))
+ end
+
+ # ── HMAC tri-state (always runs — openssl HMAC is in every Ruby/OpenSSL build) ─────────────────────
+ def test_receipt_hmac_verifies_with_key_fails_wrong_nil_without
+ sig = OpenSSL::HMAC.hexdigest("SHA256", "k", WaveDispatch.canonical_payment_receipt(RECEIPT))
+ r = RECEIPT.merge("alg" => "hmac-sha256", "sig" => sig)
+ assert_equal true, WaveDispatch.verify_payment_receipt(r, hmac_key: "k")
+ assert_equal false, WaveDispatch.verify_payment_receipt(r, hmac_key: "wrong")
+ assert_nil WaveDispatch.verify_payment_receipt(r) # HMAC without our key -> uncheckable
+ end
+
+ def test_attestation_hmac_verifies_with_key_fails_wrong_nil_without
+ sig = OpenSSL::HMAC.hexdigest("SHA256", "k", WaveDispatch.canonical_attestation(ATTEST))
+ att = ATTEST.merge("alg" => "hmac-sha256", "sig" => sig)
+ assert_equal true, WaveDispatch.verify_attestation(att, hmac_key: "k")
+ assert_equal false, WaveDispatch.verify_attestation(att, hmac_key: "wrong")
+ assert_nil WaveDispatch.verify_attestation(att)
+ end
+
+ # ── unsigned / no-alg -> nil (not false) (always runs) ─────────────────────────────────────────────
+ def test_unsigned_records_are_nil
+ assert_nil WaveDispatch.verify_payment_receipt(RECEIPT)
+ assert_nil WaveDispatch.verify_payment_receipt(RECEIPT.merge("alg" => "none", "sig" => nil))
+ assert_nil WaveDispatch.verify_attestation(ATTEST)
+ assert_nil WaveDispatch.verify_attestation(ATTEST.merge("alg" => "none", "sig" => nil))
+ end
+
+ def test_non_hash_and_unknown_alg_are_nil
+ assert_nil WaveDispatch.verify_payment_receipt("not a hash")
+ assert_nil WaveDispatch.verify_attestation(nil)
+ assert_nil WaveDispatch.verify_payment_receipt(RECEIPT.merge("alg" => "rsa-pss", "sig" => "ab"))
+ end
+
+ # ── truncation tri-state (always runs — pure data) ─────────────────────────────────────────────────
+ def test_truncation_is_derivable_from_two_hashes
+ assert_equal true, WaveDispatch.attestation_truncated("chunk_sha" => "a", "prompt_sha" => "b")
+ assert_equal false, WaveDispatch.attestation_truncated("chunk_sha" => "a", "prompt_sha" => "a")
+ assert_nil WaveDispatch.attestation_truncated("chunk_sha" => "a")
+ assert_nil WaveDispatch.attestation_truncated("prompt_sha" => "b")
+ end
+
+ # ── E4.followup: trusted-key registry (the SECOND axis). Cross-impl with sdk/js/trusted.test.js ──────
+ TRUSTED_KEY_ID = "34750f98bd59fcfc" # = sha256(RECEIPT_PUBKEY bytes)[0, 16] — the edge derivation
+ OTHER_KEY = "00" * 32
+
+ def test_make_registry_membership_and_wellknown_payload
+ reg = WaveDispatch.make_registry([{ "pubkey" => RECEIPT_PUBKEY, "key_id" => TRUSTED_KEY_ID }])
+ assert_equal 1, reg.size
+ assert reg.key?(RECEIPT_PUBKEY) # stored lowercase
+ refute reg.key?(OTHER_KEY)
+ assert_equal 1, WaveDispatch.make_registry("keys" => [RECEIPT_PUBKEY, "nothex!!", "abc"]).size
+ assert_equal 0, WaveDispatch.make_registry(nil).size
+ end
+
+ def test_trusted_signer_tristate_and_keyid_lie
+ signed = { "alg" => "ed25519", "pubkey" => RECEIPT_PUBKEY, "key_id" => TRUSTED_KEY_ID, "sig" => "ab" }
+ reg = WaveDispatch.make_registry([RECEIPT_PUBKEY])
+ assert_equal true, WaveDispatch.trusted_signer(signed, reg)
+ assert_equal true, WaveDispatch.trusted_signer(signed.merge("pubkey" => RECEIPT_PUBKEY.upcase), reg) # case-insensitive
+ assert_equal false, WaveDispatch.trusted_signer(signed, WaveDispatch.make_registry([OTHER_KEY]))
+ assert_nil WaveDispatch.trusted_signer(signed, nil)
+ assert_nil WaveDispatch.trusted_signer(signed, {})
+ assert_nil WaveDispatch.trusted_signer(signed.merge("alg" => "hmac-sha256"), reg)
+ assert_equal false, WaveDispatch.trusted_signer(signed.merge("key_id" => "deadbeefdeadbeef"), reg) # key_id lie
+ assert_equal true, WaveDispatch.trusted_signer(signed.merge("key_id" => nil), reg) # absent key_id ok
+ end
+
+ def test_registry_folds_into_verify
+ skip "Ed25519 unavailable" unless ED25519_AVAILABLE
+
+ signed = RECEIPT.merge("alg" => "ed25519", "sig" => RECEIPT_SIG, "pubkey" => RECEIPT_PUBKEY)
+ assert_equal true, WaveDispatch.verify_payment_receipt(signed) # no registry -> unchanged
+ assert_equal true, WaveDispatch.verify_payment_receipt(signed, registry: WaveDispatch.make_registry([RECEIPT_PUBKEY]))
+ assert_equal false, WaveDispatch.verify_payment_receipt(signed, registry: WaveDispatch.make_registry([OTHER_KEY])) # valid sig, untrusted
+ assert_equal false, WaveDispatch.verify_payment_receipt(signed.merge("amount_atomic" => "999999"),
+ registry: WaveDispatch.make_registry([RECEIPT_PUBKEY])) # bad sig not upgraded
+ end
+end
diff --git a/sdk/ruby/wave_dispatch.gemspec b/sdk/ruby/wave_dispatch.gemspec
index 140310d..7b28324 100644
--- a/sdk/ruby/wave_dispatch.gemspec
+++ b/sdk/ruby/wave_dispatch.gemspec
@@ -1,12 +1,12 @@
Gem::Specification.new do |s|
s.name = "wave-dispatch"
s.version = "0.7.0"
- s.summary = "wave Dispatch — local-first AI router client"
+ s.summary = "WAVE Dispatch — local-first AI router client"
s.description = "Route each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra."
s.authors = ["WAVE Online, LLC"]
s.homepage = "https://dispatch.wave.online"
s.license = "MIT"
- s.files = ["lib/wave_dispatch.rb"]
+ s.files = ["lib/wave_dispatch.rb", "lib/wave_dispatch/verify.rb"]
s.require_paths = ["lib"]
s.required_ruby_version = ">= 2.7"
s.metadata = { "source_code_uri" => "https://github.com/wave-av/dispatch-edge" }
diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml
index acbbb66..5756577 100644
--- a/sdk/rust/Cargo.toml
+++ b/sdk/rust/Cargo.toml
@@ -2,7 +2,7 @@
name = "wave-dispatch"
version = "0.7.0"
edition = "2021"
-description = "wave Dispatch — local-first AI router client. Route each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra."
+description = "WAVE Dispatch — local-first AI router client. Route each request to the cheapest capable model (local-first; escalate to your frontier only when needed). BYO keys + infra."
license = "MIT"
repository = "https://github.com/wave-av/dispatch-edge"
homepage = "https://dispatch.wave.online"
@@ -16,3 +16,7 @@ base64 = "0.22" # 0.5.1 — wallet_hook(privy) needs Basic auth encodi
urlencoding = "2" # 0.5.1 — wallet_hook(privy) URL-encodes the wallet_id path segment
p256 = { version = "0.13", features = ["pkcs8", "ecdsa"] } # 0.6.2 — CDP-JWT (ES256) signing
rand_core = "0.6" # 0.6.2 — nonce randomness for CDP-JWT
+ed25519-dalek = "2" # E4 — verify the two receipts' Ed25519 signatures (offline, trustless)
+hmac = "0.12" # E4 — HMAC-SHA256 receipt/attestation verify (constant-time verify_slice)
+sha2 = "0.10" # E4 — SHA-256 for the HMAC path
+hex = "0.4" # E4 — decode the hex pubkey/sig/hmac
diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs
index 814c16f..c462bdb 100644
--- a/sdk/rust/src/lib.rs
+++ b/sdk/rust/src/lib.rs
@@ -1,10 +1,19 @@
-//! wave Dispatch — thin Rust client. Route each request to the cheapest capable model (local-first;
+//! WAVE Dispatch — thin Rust client. Route each request to the cheapest capable model (local-first;
//! escalate to your frontier only when needed). BYO keys + infra; the service returns a routing decision.
use serde_json::{json, Value};
use std::collections::HashMap;
use std::error::Error;
use std::time::{SystemTime, UNIX_EPOCH};
+// E4 verify-everywhere: offline trustless verifiers for the two receipts (canonical byte-parity to the edge
+// signers + the other SDKs, pinned by shared vectors). Public API re-exported flat.
+mod verify;
+pub use verify::{
+ attestation_truncated, canonical_attestation, canonical_payment_receipt, make_registry,
+ trusted_signer, verify_attestation, verify_attestation_trusted, verify_payment_receipt,
+ verify_payment_receipt_trusted, Registry,
+};
+
/// 0.6.2 — CDP-JWT (ES256/P-256) signer. Uses the `p256` crate (added to dependencies). Pure Rust;
/// no openssl FFI. Header: {alg:'ES256', kid:, typ:'JWT', nonce:}. Payload:
/// {sub:, iss:'cdp', nbf:, exp:, uri:'POST dispatch.wave.online', claim:}.
diff --git a/sdk/rust/src/verify.rs b/sdk/rust/src/verify.rs
new file mode 100644
index 0000000..7ddf7ac
--- /dev/null
+++ b/sdk/rust/src/verify.rs
@@ -0,0 +1,445 @@
+// SPLIT-PLAN: the runtime code (canonical/verify/trusted/fold) is ~160 lines; the rest is `#[cfg(test)]` —
+// the Rust convention of inline tests. Splitting these into a separate file is the OPPOSITE of idiomatic and
+// would only obscure the cross-impl vector pin (the whole point of this module). Architecture is correct.
+//! verify.rs — offline, trustless verifiers for "the two receipts" in the Rust SDK (E4 verify-everywhere).
+//!
+//! A Payment-Receipt is the MONEY proof ("I paid, dispatch verified the settlement"); a context-Attestation
+//! is the PROVENANCE proof ("what the model saw"). An Ed25519 record embeds its own public key, so anyone
+//! verifies it with NO secret and NO network; HMAC records need the shared key. The canonical signing string
+//! is byte-for-byte identical to every other implementation (JS/Python/Go/Ruby + the edge signers), pinned by
+//! the shared vectors in the `tests` module below.
+use ed25519_dalek::{Signature, VerifyingKey};
+use hmac::{Hmac, Mac};
+use serde_json::Value;
+use sha2::{Digest, Sha256};
+use std::collections::{BTreeMap, HashSet};
+
+type HmacSha256 = Hmac;
+
+// The exact field sets shared by every signer. canonical() uses ONLY these, sorted (BTreeMap), missing => null.
+const RECEIPT_FIELDS: [&str; 11] = [
+ "v",
+ "ts",
+ "protocol",
+ "mode",
+ "resource",
+ "network",
+ "asset",
+ "amount_atomic",
+ "pay_to",
+ "tx_hash",
+ "verified",
+];
+const ATTEST_FIELDS: [&str; 11] = [
+ "v",
+ "ts",
+ "model",
+ "source",
+ "chunk",
+ "chunk_sha",
+ "chunk_chars",
+ "num_ctx",
+ "prompt_sha",
+ "kept",
+ "dropped_hallucinated",
+];
+
+// Compact, key-sorted, ASCII-escaped JSON over exactly `fields`. A BTreeMap serializes its keys in sorted
+// order; serde_json::to_string is compact and does NOT HTML-escape <>& (which we also must not); ascii_escape
+// then matches the JS asciiEscape(JSON.stringify) / Python ensure_ascii shape.
+fn canonical(record: &Value, fields: &[&str]) -> String {
+ let mut sorted: BTreeMap<&str, Value> = BTreeMap::new();
+ for &k in fields {
+ sorted.insert(k, record.get(k).cloned().unwrap_or(Value::Null));
+ }
+ ascii_escape(&serde_json::to_string(&sorted).unwrap_or_default())
+}
+
+// Rewrite every char >= U+0080 as \uXXXX (lowercase, 4 hex digits); chars above U+FFFF become a UTF-16
+// surrogate pair, matching JS JSON's UTF-16 code-unit escaping and Python's ensure_ascii.
+fn ascii_escape(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ let cp = c as u32;
+ if cp < 0x80 {
+ out.push(c);
+ } else if cp <= 0xFFFF {
+ out.push_str(&format!("\\u{:04x}", cp));
+ } else {
+ let mut buf = [0u16; 2];
+ for &unit in c.encode_utf16(&mut buf).iter() {
+ out.push_str(&format!("\\u{:04x}", unit));
+ }
+ }
+ }
+ out
+}
+
+/// Canonical signing string for a Payment-Receipt — byte-identical to the edge signer.
+pub fn canonical_payment_receipt(receipt: &Value) -> String {
+ canonical(receipt, &RECEIPT_FIELDS)
+}
+
+/// Canonical signing string for a context-Attestation.
+pub fn canonical_attestation(att: &Value) -> String {
+ canonical(att, &ATTEST_FIELDS)
+}
+
+// Tri-state core: None when unsigned / no alg / alg "none" / no sig / ed25519 without pubkey / hmac without
+// the key / unknown alg; otherwise a real true/false crypto verdict.
+fn verify_record(record: &Value, canonical_str: &str, hmac_key: Option<&str>) -> Option {
+ let alg = record.get("alg").and_then(Value::as_str)?;
+ let sig = record.get("sig").and_then(Value::as_str)?;
+ if alg.is_empty() || alg == "none" {
+ return None;
+ }
+ match alg {
+ "ed25519" => {
+ let pubkey = record.get("pubkey").and_then(Value::as_str)?; // None if no pubkey
+ Some(verify_ed25519(pubkey, sig, canonical_str.as_bytes()))
+ }
+ "hmac-sha256" => {
+ let key = hmac_key?; // None if the key was not supplied
+ Some(verify_hmac(key, sig, canonical_str.as_bytes()))
+ }
+ _ => None,
+ }
+}
+
+// Raw 32-byte public key (hex) + raw 64-byte signature (hex) over the canonical bytes. Any malformed hex /
+// wrong length is a hard false (never a panic) — None is reserved for "no pubkey supplied".
+fn verify_ed25519(pubkey_hex: &str, sig_hex: &str, msg: &[u8]) -> bool {
+ let pub_bytes = match hex::decode(pubkey_hex) {
+ Ok(b) => b,
+ Err(_) => return false,
+ };
+ let sig_bytes = match hex::decode(sig_hex) {
+ Ok(b) => b,
+ Err(_) => return false,
+ };
+ let pub_arr: [u8; 32] = match pub_bytes.try_into() {
+ Ok(a) => a,
+ Err(_) => return false,
+ };
+ let sig_arr: [u8; 64] = match sig_bytes.try_into() {
+ Ok(a) => a,
+ Err(_) => return false,
+ };
+ match VerifyingKey::from_bytes(&pub_arr) {
+ Ok(vk) => vk
+ .verify_strict(msg, &Signature::from_bytes(&sig_arr))
+ .is_ok(),
+ Err(_) => false,
+ }
+}
+
+fn verify_hmac(key: &str, sig_hex: &str, msg: &[u8]) -> bool {
+ let sig_bytes = match hex::decode(sig_hex) {
+ Ok(b) => b,
+ Err(_) => return false,
+ };
+ let mut mac = match HmacSha256::new_from_slice(key.as_bytes()) {
+ Ok(m) => m,
+ Err(_) => return false,
+ };
+ mac.update(msg);
+ mac.verify_slice(&sig_bytes).is_ok() // constant-time
+}
+
+/// Verify a Payment-Receipt. `Some(true)`/`Some(false)` when signed & checkable, `None` when unsigned or an
+/// HMAC receipt without its key. Ed25519 is self-describing — offline & trustless (no secret, no network).
+pub fn verify_payment_receipt(receipt: &Value, hmac_key: Option<&str>) -> Option {
+ verify_record(receipt, &canonical_payment_receipt(receipt), hmac_key)
+}
+
+/// Verify a context-Attestation (same tri-state scheme as `verify_payment_receipt`).
+pub fn verify_attestation(att: &Value, hmac_key: Option<&str>) -> Option {
+ verify_record(att, &canonical_attestation(att), hmac_key)
+}
+
+/// Did the model see less than the full prompt? `chunk_sha` != `prompt_sha` => `Some(true)`, equal =>
+/// `Some(false)`, either missing => `None` (undeterminable). Pure data, no crypto.
+pub fn attestation_truncated(att: &Value) -> Option {
+ let c = att.get("chunk_sha").and_then(Value::as_str);
+ let p = att.get("prompt_sha").and_then(Value::as_str);
+ match (c, p) {
+ (Some(c), Some(p)) if !c.is_empty() && !p.is_empty() => Some(c != p),
+ _ => None,
+ }
+}
+
+// ── trusted-key registry (E4.followup): trust's SECOND axis — "is the signer one WAVE published?" ──
+// trusted_signer answers registry membership + key_id honesty (NOT the signature; compose with verify_*, or
+// use verify_*_trusted for the conjunction). Rust has no optional args, so the fold is a sibling fn rather
+// than an extra param. Mirrors sdk/js/verify.js byte-for-byte (pinned by the shared FIXED vector).
+
+/// A set of trusted signer pubkeys (lowercase hex), built by [`make_registry`].
+pub struct Registry(HashSet);
+
+impl Registry {
+ /// Number of distinct trusted keys.
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+ /// True when the registry holds no keys (it then decides nothing).
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+ /// Is `pubkey_hex` (case-insensitive) a trusted key?
+ pub fn has(&self, pubkey_hex: &str) -> bool {
+ self.0.contains(&pubkey_hex.to_lowercase())
+ }
+}
+
+fn is_hex(s: &str) -> bool {
+ s.len() % 2 == 0 && s.chars().all(|c| c.is_ascii_hexdigit())
+}
+
+/// Build a trusted-key registry from a serde_json value: an array of pubkey-hex strings or `{pubkey, key_id?}`
+/// objects, or a `/.well-known/wave-keys.json` payload `{"keys": [...]}`. Malformed entries are dropped.
+pub fn make_registry(entries: &Value) -> Registry {
+ let list = entries.get("keys").unwrap_or(entries);
+ let mut set = HashSet::new();
+ if let Some(arr) = list.as_array() {
+ for e in arr {
+ let pubkey = e.as_str().or_else(|| e.get("pubkey").and_then(Value::as_str));
+ if let Some(p) = pubkey {
+ if !p.is_empty() && is_hex(p) {
+ set.insert(p.to_lowercase());
+ }
+ }
+ }
+ }
+ Registry(set)
+}
+
+fn key_id_of(pubkey_hex: &str) -> String {
+ match hex::decode(pubkey_hex) {
+ Ok(bytes) => hex::encode(Sha256::digest(&bytes))[..16].to_string(), // the edge derivation
+ Err(_) => String::new(),
+ }
+}
+
+/// Is the signer's key one WAVE published? Tri-state: `Some(true)` = ed25519 key in `registry` with an honest
+/// key_id; `Some(false)` = a valid-shaped key NOT in the registry, or a key_id that lies about its pubkey;
+/// `None` = cannot decide (empty registry, unsigned, "none", or HMAC). Does NOT verify the signature.
+pub fn trusted_signer(record: &Value, registry: &Registry) -> Option {
+ if registry.is_empty() || record.get("alg").and_then(Value::as_str) != Some("ed25519") {
+ return None;
+ }
+ let pubkey = record
+ .get("pubkey")
+ .and_then(Value::as_str)
+ .filter(|s| !s.is_empty())?;
+ let pub_lc = pubkey.to_lowercase();
+ if !is_hex(&pub_lc) {
+ return Some(false);
+ }
+ if let Some(kid) = record.get("key_id").and_then(Value::as_str) {
+ if kid.to_lowercase() != key_id_of(&pub_lc) {
+ return Some(false); // key_id lies about its pubkey
+ }
+ }
+ Some(registry.has(&pub_lc))
+}
+
+fn fold(v: Option, record: &Value, registry: &Registry) -> Option {
+ if v == Some(true) && trusted_signer(record, registry) == Some(false) {
+ return Some(false); // valid signature, but the key is not WAVE's
+ }
+ v // a registry never upgrades an invalid signature
+}
+
+/// Verify a Payment-Receipt AND require the signer be in `registry` (the trusted-key fold): a valid signature
+/// from an untrusted key returns `Some(false)`. For the signature-only check use [`verify_payment_receipt`].
+pub fn verify_payment_receipt_trusted(
+ receipt: &Value,
+ registry: &Registry,
+ hmac_key: Option<&str>,
+) -> Option {
+ fold(verify_payment_receipt(receipt, hmac_key), receipt, registry)
+}
+
+/// Verify a context-Attestation with the trusted-key fold (see [`verify_payment_receipt_trusted`]).
+pub fn verify_attestation_trusted(
+ att: &Value,
+ registry: &Registry,
+ hmac_key: Option<&str>,
+) -> Option {
+ fold(verify_attestation(att, hmac_key), att, registry)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn fixed_receipt() -> Value {
+ json!({"v":"wave.payment-receipt/v0","ts":1700000000,"protocol":"x402","mode":"wave-x402","resource":"/extract","network":"base","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","amount_atomic":"1000","pay_to":"0x0000000000000000000000000000000000000001","tx_hash":"0xdeadbeef","verified":true})
+ }
+ const EXPECTED_RECEIPT_CANONICAL: &str = r#"{"amount_atomic":"1000","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","mode":"wave-x402","network":"base","pay_to":"0x0000000000000000000000000000000000000001","protocol":"x402","resource":"/extract","ts":1700000000,"tx_hash":"0xdeadbeef","v":"wave.payment-receipt/v0","verified":true}"#;
+ const RECEIPT_PUBKEY: &str = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c";
+ const RECEIPT_SIG: &str = "e333772435ba2b16c9b52188a489de18f0dbba870a84b6dcc6fe63ef3dffae5ab9e65daa85b3306d4d3df879bc47c6282f87c661e634390a9a5d93e7c502220a";
+
+ fn base_att() -> Value {
+ json!({"v":"wave.context-attestation/v0","ts":1700000000,"model":"qwen3:30b","source":"café.md","chunk":2,"chunk_sha":"deadbeef","chunk_chars":2980,"num_ctx":8192,"prompt_sha":"cafe1234","kept":3,"dropped_hallucinated":1})
+ }
+ // café (real é in the source field) -> café in the canonical OUTPUT (raw string keeps it as 6 literal chars).
+ const PY_ATT_CANONICAL: &str = r#"{"chunk":2,"chunk_chars":2980,"chunk_sha":"deadbeef","dropped_hallucinated":1,"kept":3,"model":"qwen3:30b","num_ctx":8192,"prompt_sha":"cafe1234","source":"caf\u00e9.md","ts":1700000000,"v":"wave.context-attestation/v0"}"#;
+ const ATT_PUBKEY: &str = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8";
+ const ATT_SIG: &str = "68017540b4c3b060e51faab64c28f4e1ea6994d3781fec14878e73052046d78d05757a8287af644b245b10274ba3b60329ed216472839d7573debf4256e9730b";
+
+ fn with(base: &Value, over: Value) -> Value {
+ let mut m = base.as_object().unwrap().clone();
+ for (k, v) in over.as_object().unwrap() {
+ m.insert(k.clone(), v.clone());
+ }
+ Value::Object(m)
+ }
+ fn hmac_hex(key: &str, msg: &str) -> String {
+ let mut mac = HmacSha256::new_from_slice(key.as_bytes()).unwrap();
+ mac.update(msg.as_bytes());
+ hex::encode(mac.finalize().into_bytes().to_vec())
+ }
+
+ #[test]
+ fn receipt_canonical() {
+ assert_eq!(
+ canonical_payment_receipt(&fixed_receipt()),
+ EXPECTED_RECEIPT_CANONICAL
+ );
+ }
+ #[test]
+ fn attestation_canonical_cafe() {
+ assert_eq!(canonical_attestation(&base_att()), PY_ATT_CANONICAL);
+ }
+ #[test]
+ fn receipt_ed25519() {
+ let signed = with(
+ &fixed_receipt(),
+ json!({"alg":"ed25519","sig":RECEIPT_SIG,"pubkey":RECEIPT_PUBKEY}),
+ );
+ assert_eq!(verify_payment_receipt(&signed, None), Some(true));
+ assert_eq!(
+ verify_payment_receipt(&with(&signed, json!({"amount_atomic":"999999"})), None),
+ Some(false)
+ );
+ assert_eq!(
+ verify_payment_receipt(&with(&signed, json!({"verified":false})), None),
+ Some(false)
+ );
+ }
+ #[test]
+ fn attestation_ed25519() {
+ let signed = with(
+ &base_att(),
+ json!({"alg":"ed25519","sig":ATT_SIG,"pubkey":ATT_PUBKEY}),
+ );
+ assert_eq!(verify_attestation(&signed, None), Some(true));
+ assert_eq!(
+ verify_attestation(&with(&signed, json!({"kept":999})), None),
+ Some(false)
+ );
+ }
+ #[test]
+ fn hmac_tristate() {
+ let r = with(
+ &fixed_receipt(),
+ json!({"alg":"hmac-sha256","sig":hmac_hex("k", EXPECTED_RECEIPT_CANONICAL)}),
+ );
+ assert_eq!(verify_payment_receipt(&r, Some("k")), Some(true));
+ assert_eq!(verify_payment_receipt(&r, Some("wrong")), Some(false));
+ assert_eq!(verify_payment_receipt(&r, None), None);
+ let a = with(
+ &base_att(),
+ json!({"alg":"hmac-sha256","sig":hmac_hex("k", PY_ATT_CANONICAL)}),
+ );
+ assert_eq!(verify_attestation(&a, Some("k")), Some(true));
+ assert_eq!(verify_attestation(&a, None), None);
+ }
+ #[test]
+ fn unsigned() {
+ assert_eq!(verify_payment_receipt(&fixed_receipt(), None), None);
+ assert_eq!(
+ verify_payment_receipt(
+ &with(&fixed_receipt(), json!({"alg":"none","sig":null})),
+ None
+ ),
+ None
+ );
+ assert_eq!(verify_attestation(&base_att(), None), None);
+ }
+ #[test]
+ fn truncated() {
+ assert_eq!(
+ attestation_truncated(&json!({"chunk_sha":"a","prompt_sha":"b"})),
+ Some(true)
+ );
+ assert_eq!(
+ attestation_truncated(&json!({"chunk_sha":"a","prompt_sha":"a"})),
+ Some(false)
+ );
+ assert_eq!(attestation_truncated(&json!({"chunk_sha":"a"})), None);
+ }
+
+ // ── trusted-key registry (E4.followup) — same FIXED vector as sdk/js/trusted.test.js + the other ports ──
+ const KEY_ID: &str = "34750f98bd59fcfc"; // sha256(RECEIPT_PUBKEY bytes)[:16] — the edge derivation
+ const OTHER: &str = "0000000000000000000000000000000000000000000000000000000000000000";
+
+ #[test]
+ fn make_registry_membership() {
+ let reg = make_registry(&json!([{"pubkey": RECEIPT_PUBKEY, "key_id": KEY_ID}]));
+ assert_eq!(reg.len(), 1);
+ assert!(reg.has(RECEIPT_PUBKEY));
+ assert!(reg.has(&RECEIPT_PUBKEY.to_uppercase())); // case-insensitive
+ assert!(!reg.has(OTHER));
+ assert_eq!(
+ make_registry(&json!({"keys":[RECEIPT_PUBKEY,"nothex!!","abc"]})).len(),
+ 1
+ );
+ assert!(make_registry(&json!([])).is_empty());
+ }
+
+ #[test]
+ fn trusted_signer_tristate() {
+ let signed = json!({"alg":"ed25519","pubkey":RECEIPT_PUBKEY,"key_id":KEY_ID,"sig":"ab"});
+ let reg = make_registry(&json!([RECEIPT_PUBKEY]));
+ assert_eq!(trusted_signer(&signed, ®), Some(true)); // ours
+ assert_eq!(
+ trusted_signer(&signed, &make_registry(&json!([OTHER]))),
+ Some(false)
+ ); // valid shape, not trusted
+ assert_eq!(trusted_signer(&signed, &make_registry(&json!([]))), None); // empty
+ assert_eq!(
+ trusted_signer(&with(&signed, json!({"alg":"hmac-sha256"})), ®),
+ None
+ ); // HMAC -> can't vouch
+ assert_eq!(
+ trusted_signer(&with(&signed, json!({"key_id":"deadbeefdeadbeef"})), ®),
+ Some(false)
+ ); // key_id lie
+ }
+
+ #[test]
+ fn registry_folds_into_verify() {
+ let signed = with(
+ &fixed_receipt(),
+ json!({"alg":"ed25519","sig":RECEIPT_SIG,"pubkey":RECEIPT_PUBKEY}),
+ );
+ let reg = make_registry(&json!([RECEIPT_PUBKEY]));
+ assert_eq!(verify_payment_receipt(&signed, None), Some(true)); // no registry -> unchanged
+ assert_eq!(verify_payment_receipt_trusted(&signed, ®, None), Some(true)); // valid + ours
+ assert_eq!(
+ verify_payment_receipt_trusted(&signed, &make_registry(&json!([OTHER])), None),
+ Some(false)
+ ); // valid sig, untrusted key
+ assert_eq!(
+ verify_payment_receipt_trusted(
+ &with(&signed, json!({"amount_atomic":"999999"})),
+ ®,
+ None
+ ),
+ Some(false)
+ ); // registry never upgrades a bad sig
+ }
+}
diff --git a/threat-model.md b/threat-model.md
index 263b6f2..17259ff 100644
--- a/threat-model.md
+++ b/threat-model.md
@@ -1,4 +1,4 @@
-# wave Dispatch — threat model (#102)
+# WAVE Dispatch — threat model (#102)
Adversarial review of the public surface (`dispatch.wave.online` + the local stack). ✅ = mitigated &
verified, ⚠️ = partial, ☐ = open. The control-plane/data-plane split means the customer's keys, data,