@fangorn-network/westmarch 0.0.1-dev → 0.0.2-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/consume/apps.js +4 -2
  3. package/consume/corpora.js +1 -1
  4. package/consume/directory.js +1 -1
  5. package/consume/embed.js +1 -1
  6. package/consume/lint.js +1 -1
  7. package/consume/mcp-bin.js +6 -0
  8. package/consume/mcp.js +193 -34
  9. package/consume/ort-preload.js +25 -0
  10. package/consume/rank.js +1 -1
  11. package/consume/roles.js +6 -4
  12. package/consume/shard.js +1 -1
  13. package/consume/steam.js +1 -1
  14. package/consume/taste-doc.js +1 -1
  15. package/consume/taste.js +1 -1
  16. package/consume/tools.js +28 -5
  17. package/consume/ui.js +1 -1
  18. package/consume/view-tools.js +202 -0
  19. package/consume/x402.js +94 -0
  20. package/docs/app-to-agent.md +553 -185
  21. package/package.json +11 -4
  22. package/publish/agent-card.js +12 -2
  23. package/publish/cohort.js +1 -1
  24. package/publish/demand.js +1 -1
  25. package/publish/enrich.js +1 -1
  26. package/publish/envelope.js +1 -1
  27. package/publish/graph.js +1 -1
  28. package/publish/manifest.js +1 -1
  29. package/publish/reactions.js +4 -2
  30. package/publish/settle.js +1 -1
  31. package/publish/ship.js +378 -0
  32. package/publish/terms.js +1 -1
  33. package/publish/vault.js +1 -1
  34. package/publish/view-cli.js +39 -0
  35. package/publish/view.js +399 -0
  36. package/site/agent.js +36 -0
  37. package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
  38. package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
  39. package/site/dist/assets/index-DlzVyQ_9.js +6 -0
  40. package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
  41. package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
  42. package/site/dist/assets/roles-C9V_uDr_.js +1 -0
  43. package/site/dist/assets/roles-YDRjv_PY.js +1 -0
  44. package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  45. package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
  46. package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
  47. package/site/dist/index.html +20 -0
  48. package/site/feed.js +62 -0
@@ -0,0 +1,202 @@
1
+ // An app's views, served as tools, with no browser.
2
+ //
3
+ // A Fangorn app's card names its views: static, content-addressed shards any
4
+ // HTTP client can read. The page's WebMCP tools are one way in, but they need a
5
+ // browser, and a browser costs seconds and gigabytes before the first answer.
6
+ // These tools read the views directly with westmarch's own search, browse,
7
+ // count, get and similar, so the only code that runs is ours. The app's data is
8
+ // verified by its manifest's sha256, not trusted.
9
+ //
10
+ // Time to first answer is the point, so:
11
+ // - a search goes to the 3 views whose coverage sketch best matches the query,
12
+ // not to all of them (Kingsfoil: ~3 of 23 views, ~92% of the true top 10 with
13
+ // a 32-vector sketch). The result names what was searched and what was not.
14
+ // - `warm()` starts the embedder the moment an app is opened, and a search
15
+ // never waits on a first-time model download.
16
+ //
17
+ // `get`/`similar`/`count` answer over what is loaded, and say so.
18
+
19
+ import { configure, loadShard, trimView } from "./shard.js";
20
+ import { rolesFrom, textOf } from "./roles.js";
21
+ import { browse, facet, getRow, neighbors, search } from "./tools.js";
22
+ import { lexScore, rankDomains } from "./rank.js";
23
+ import { existsSync } from "node:fs";
24
+ import { EMBED_MODEL, embedQueryDirect, warmDirect } from "./embed.js";
25
+
26
+ const ROUTE_TOP = 3;
27
+
28
+ // One roles table per view, set from its manifests before its first row parses.
29
+ const rolesByView = new Map();
30
+ const DEFAULT_ROLES = rolesFrom([]);
31
+ const rolesOf = (view) => rolesByView.get(view) ?? DEFAULT_ROLES;
32
+ configure({
33
+ onManifests: (manifests, view) => rolesByView.set(view, rolesFrom(manifests)),
34
+ rowText: (f, view) => textOf(f, rolesOf(view)),
35
+ });
36
+
37
+ // ponytail: an embedder that fails to load is remembered as failed, and search
38
+ // ranks by words for the rest of the process. Retry when that matters.
39
+ let embedder = null, ready = false, downloading = false;
40
+ /** `cacheDir`: where the model is kept. Outside node_modules, or every new
41
+ * install of this package downloads the same 131MB again. */
42
+ export const warm = ({ cacheDir } = {}) => (embedder ??= (async () => {
43
+ if (cacheDir) {
44
+ downloading = !existsSync(`${cacheDir}/${EMBED_MODEL}/onnx/model_quantized.onnx`); // before any await
45
+ (await import("@huggingface/transformers")).env.cacheDir = cacheDir;
46
+ }
47
+ await warmDirect();
48
+ })().then(() => (ready = true), (e) => { warmError = e?.message ?? String(e); return false; })
49
+ .finally(() => { downloading = false; }));
50
+ /** Why the embedder did not load, if it did not. */
51
+ export let warmError = null;
52
+ // A model on disk loads in ~0.5s and is worth the wait. A download is 131MB, so
53
+ // until it lands, search ranks by words and says so, rather than stall ~10s.
54
+ const queryVector = async (q) => {
55
+ if (!ready && downloading) return null;
56
+ return (await warm()) ? embedQueryDirect(q).catch(() => null) : null;
57
+ };
58
+ const rankedBy = (qv) => qv ? "meaning"
59
+ : downloading ? "words (the embedding model is downloading; meaning once it lands)"
60
+ : "words (embedder unavailable)";
61
+
62
+ const text = (o) => ({ content: [{ type: "text", text: JSON.stringify(o, null, 1) }] });
63
+ const str = (description) => ({ type: "string", description });
64
+ const num = (description) => ({ type: "number", description });
65
+ const VIEWS = { type: "array", items: { type: "string" }, description: 'View names from describe, or ["all"]. Omit to let the query choose.' };
66
+ const WHERE = { type: "object", description: 'Equality filters, e.g. {"phase":"PHASE3"}' };
67
+
68
+ /**
69
+ * The tools for one app, or null when its card names no views.
70
+ * `app` is `{ name, desc, views }` from a verified card. `fetchCatalog(url)` may
71
+ * answer from a stale copy: the survey only describes and routes, and loading a
72
+ * view re-reads its catalog through shard.js either way.
73
+ */
74
+ export function viewTools(app, { fetchCatalog = fetch } = {}) {
75
+ if (!app.views?.length) return null;
76
+ const names = new Map(); // view url → its name in every result
77
+ const label = (url) => names.get(url) ?? url.split("/").pop();
78
+ let surveyed;
79
+ // The catalogs: what each view holds and its coverage sketch. Small, and no rows.
80
+ const survey = () => (surveyed ??= Promise.all(app.views.map(async (url) => {
81
+ const view = trimView(url);
82
+ const { domains = [] } = await (await fetchCatalog(`${view}/cdn/catalog`)).json();
83
+ names.set(view, domains.length === 1 ? domains[0].name : label(view));
84
+ return {
85
+ view, name: label(view),
86
+ description: domains.map((d) => d.description).filter(Boolean).join(" "),
87
+ count: domains.reduce((s, d) => s + (d.count ?? 0), 0),
88
+ coverage: { vectors: domains.flatMap((d) => d.coverage?.vectors ?? []) },
89
+ };
90
+ })).catch((e) => { surveyed = null; throw e; }));
91
+
92
+ survey().catch(() => {}); // started now, so describe and the first search do not wait on it
93
+ const loaded = new Map(); // view → rows
94
+ const load = async (v) => { if (!loaded.has(v.view)) loaded.set(v.view, await loadShard(v.view)); return loaded.get(v.view); };
95
+ const pick = async (names) => {
96
+ const all = await survey();
97
+ if (names?.includes("all")) return all;
98
+ const bad = names.filter((n) => !all.some((v) => v.name === n || label(v.view) === n));
99
+ if (bad.length) throw new Error(`unknown view(s) ${bad.join(", ")}; views: ${all.map((v) => v.name).join(", ")}`);
100
+ return all.filter((v) => names.includes(v.name) || names.includes(label(v.view)));
101
+ };
102
+ // The query decides: coverage sketch by meaning, else the descriptions by words.
103
+ const route = async (query, qv) => {
104
+ const all = await survey();
105
+ return qv ? rankDomains(all, qv)
106
+ : all.map((v) => ({ ...v, affinity: lexScore({ text: `${v.name} ${v.description}` }, query) }))
107
+ .sort((a, b) => b.affinity - a.affinity);
108
+ };
109
+ const loadedRows = () => [...loaded.entries()].flatMap(([view, rows]) => rows.map((r) => ({ r, view })));
110
+
111
+ const run = {
112
+ describe: async () => ({
113
+ app: app.name, description: app.desc,
114
+ views: (await survey()).map((v) => ({ view: v.name, rows: v.count, description: v.description, loaded: loaded.has(v.view) })),
115
+ }),
116
+ search: async ({ query, limit = 10, views, where: w }) => {
117
+ if (!query) throw new Error("query is required");
118
+ const qv = await queryVector(query);
119
+ const ranked = views?.length ? await pick(views) : (await route(query, qv)).slice(0, ROUTE_TOP);
120
+ const others = views?.length ? [] : (await route(query, qv)).slice(ROUTE_TOP, ROUTE_TOP + 5).map((v) => v.name);
121
+ const per = await Promise.all(ranked.map(async (v) =>
122
+ search(await load(v), query, rolesOf(v.view), { qv, limit, where: w }).map((h) => ({ ...h, view: v.name }))));
123
+ const hits = per.flat().sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, limit);
124
+ return { query, ranked_by: rankedBy(qv), searched: ranked.map((v) => v.name),
125
+ next_views: others, hits };
126
+ },
127
+ get: async ({ id, view }) => {
128
+ if (view) for (const v of await pick([view])) await load(v);
129
+ for (const [v, rows] of loaded) { const r = getRow(rows, id, rolesOf(v)); if (r) return r; }
130
+ return { error: `${id} is not in a loaded view. Search for it, or pass its view.`, loaded: [...loaded.keys()].map(label) };
131
+ },
132
+ similar: async ({ id, limit = 10, views }) => {
133
+ const hit = loadedRows().find(({ r }) => r.id === id);
134
+ if (!hit) return { error: `${id} is not loaded. Search for it first.` };
135
+ // Neighbours by design live anywhere, so route on the row's own vector.
136
+ const targets = views?.length ? await pick(views)
137
+ : hit.r.vector ? (await route("", hit.r.vector)).slice(0, ROUTE_TOP) : [];
138
+ await Promise.all(targets.map(load));
139
+ const rows = [...loaded.values()].flat();
140
+ const { seed, near } = neighbors(rows, id, rolesOf(hit.view), { limit });
141
+ return { id, seed, searched: [...loaded.keys()].map(label), hits: near };
142
+ },
143
+ count: async ({ field, views, limit = 20, where: w }) => {
144
+ if (!field) throw new Error("field is required");
145
+ if (views?.length) await Promise.all((await pick(views)).map(load));
146
+ if (!loaded.size) return { error: 'nothing loaded yet. Pass views (or ["all"]) to count over.' };
147
+ return { field, over: [...loaded.keys()].map(label), ...facet([...loaded.values()].flat(), field, { limit, where: w }) };
148
+ },
149
+ browse: async ({ view, limit = 20, offset = 0, sort, where: w }) => {
150
+ const [v] = await pick([view]);
151
+ return { view: v.name, ...browse(await load(v), rolesOf(v.view), { limit, offset, sort, where: w }) };
152
+ },
153
+ };
154
+
155
+ const n = app.name;
156
+ const tools = [
157
+ { name: "describe", description: `What ${n} holds: its views, each with a description and row count. Downloads no rows; call it first.`,
158
+ inputSchema: { type: "object", properties: {} } },
159
+ { name: "search", description: `Search ${n} by meaning. Without views, searches the ${ROUTE_TOP} views whose coverage best matches the query and names the next candidates; pass views to widen.`,
160
+ inputSchema: { type: "object", properties: { query: str("A question in natural language"), limit: num("Max hits (default 10)"), views: VIEWS, where: WHERE }, required: ["query"] } },
161
+ { name: "get", description: `One record of ${n} in full, by id.`,
162
+ inputSchema: { type: "object", properties: { id: str("The record id, as search returns it"), view: str("Its view, if it has not been loaded") }, required: ["id"] } },
163
+ { name: "similar", description: `Records of ${n} nearest to one record, across the views its vector points to.`,
164
+ inputSchema: { type: "object", properties: { id: str("A record id from search"), limit: num("Max results (default 10)"), views: VIEWS }, required: ["id"] } },
165
+ { name: "count", description: `Count the distinct values of a field in ${n}: the shape of a result set before reading it.`,
166
+ inputSchema: { type: "object", properties: { field: str("Field name, e.g. from get"), views: VIEWS, limit: num("Max values (default 20)"), where: WHERE }, required: ["field"] } },
167
+ { name: "browse", description: `List one view of ${n} without a query, with filters, sorting and paging.`,
168
+ inputSchema: { type: "object", properties: { view: str("View name from describe"), limit: num("default 20"), offset: num("default 0"), sort: str("Field to sort by"), where: WHERE }, required: ["view"] } },
169
+ ];
170
+ // Records the app sells over x402 (its card's `paid`). Paid from this server's wallet,
171
+ // never above FANGORN_MCP_MAX_PRICE, and checked against the sha256 the app published
172
+ // for that record, so what was paid for is what was committed.
173
+ if (app.paid) {
174
+ const p = app.paid, dec = p.decimals ?? 6;
175
+ const price = `${(Number(p.price) / 10 ** dec).toFixed(dec).replace(/0+$/, "").replace(/\.$/, "")} ${p.symbol ?? "USDC"}`;
176
+ const cap = BigInt(Math.round(Number(process.env.FANGORN_MCP_MAX_PRICE ?? "0.10") * 10 ** dec));
177
+ run.buy = async ({ id }) => {
178
+ if (!id) throw new Error("id is required");
179
+ const hit = loadedRows().find(({ r, view }) => r.id === id || r[rolesOf(view).identity] === id);
180
+ const key = hit ? hit.r[rolesOf(hit.view).identity] ?? id : id;
181
+ const { payAndFetch } = await import("./x402.js");
182
+ const r = await payAndFetch(p.url.replace("{id}", encodeURIComponent(key)),
183
+ { privateKey: process.env.FANGORN_MCP_WALLET_KEY, maxPrice: cap });
184
+ if (r.status !== 200) return { id: key, error: `HTTP ${r.status}: ${r.body.slice(0, 200)}` };
185
+ const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(r.body)))]
186
+ .map((b) => b.toString(16).padStart(2, "0")).join("");
187
+ const want = hit?.r.paid_sha256;
188
+ return { id: key, paid: r.price ? price : "free", transaction: r.receipt?.transaction ?? null, network: r.receipt?.network ?? p.network,
189
+ verified: want ? (want === digest ? "matches the published sha256" : `MISMATCH: published ${want}, got ${digest}`) : "not checked (search for the record first)",
190
+ record: JSON.parse(r.body) };
191
+ };
192
+ tools.push({ name: "buy", description: `Buy one ${n} record's paid detail for ${price} (x402 on ${p.network}): ${p.description || "the structured record"}. ` +
193
+ "Pass an id from search. Paid from this server's wallet (FANGORN_MCP_WALLET_KEY), never above FANGORN_MCP_MAX_PRICE.",
194
+ inputSchema: { type: "object", properties: { id: str("A record id from search or get") }, required: ["id"] } });
195
+ }
196
+
197
+ return { tools, call: async (name, args = {}) => {
198
+ if (!run[name]) throw new Error(`no tool ${name}`);
199
+ return text(await run[name](args));
200
+ } };
201
+ }
202
+
@@ -0,0 +1,94 @@
1
+ // Paying for a resource over x402 (v1, "exact" scheme): the buyer's half.
2
+ //
3
+ // const { body, receipt, price } = await payAndFetch(url, { privateKey, maxPrice: 50000n });
4
+ //
5
+ // 1. GET url → 402 with `accepts`: what it costs, in which token, on which chain, to whom.
6
+ // 2. Sign an EIP-3009 transferWithAuthorization for exactly that (EIP-712, the token's own
7
+ // domain). Nothing is sent on chain by the buyer: the seller's facilitator submits it.
8
+ // 3. GET url again with X-PAYMENT: the seller verifies, settles, and returns the resource,
9
+ // with the settlement (the tx hash) in X-PAYMENT-RESPONSE.
10
+ //
11
+ // `maxPrice` (token base units) is checked BEFORE signing: a seller cannot ask for more
12
+ // than the caller agreed to, whatever its 402 says.
13
+
14
+ const CHAIN_IDS = { "arbitrum-sepolia": 421614, arbitrum: 42161, "base-sepolia": 84532, base: 8453 };
15
+ const b64 = (s) => (typeof btoa === "function" ? btoa(s) : Buffer.from(s, "utf8").toString("base64"));
16
+ const unb64 = (s) => (typeof atob === "function" ? atob(s) : Buffer.from(s, "base64").toString("utf8"));
17
+
18
+ /** The one requirement we can pay: exact scheme, a chain we know, within `maxPrice`. */
19
+ export function choose(accepts = [], { maxPrice, networks = Object.keys(CHAIN_IDS) } = {}) {
20
+ const ok = accepts.filter((a) => a.scheme === "exact" && networks.includes(a.network));
21
+ if (!ok.length) throw new Error(`no payable option: ${JSON.stringify(accepts.map((a) => `${a.scheme}/${a.network}`))}`);
22
+ const cheapest = ok.sort((a, b) => (BigInt(a.maxAmountRequired) < BigInt(b.maxAmountRequired) ? -1 : 1))[0];
23
+ if (maxPrice != null && BigInt(cheapest.maxAmountRequired) > BigInt(maxPrice))
24
+ throw new Error(`price ${cheapest.maxAmountRequired} exceeds the cap ${maxPrice} (token base units)`);
25
+ return cheapest;
26
+ }
27
+
28
+ /** The X-PAYMENT header value for requirement `req`, signed by `account` (a viem account). */
29
+ export async function paymentHeader(req, account, { now = Math.floor(Date.now() / 1000) } = {}) {
30
+ const chainId = CHAIN_IDS[req.network];
31
+ if (!chainId) throw new Error(`unknown network ${req.network}`);
32
+ const nonce = `0x${[...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
33
+ const authorization = {
34
+ from: account.address, to: req.payTo, value: String(req.maxAmountRequired),
35
+ validAfter: String(now - 600), validBefore: String(now + (req.maxTimeoutSeconds ?? 120)), nonce,
36
+ };
37
+ const signature = await account.signTypedData({
38
+ domain: { name: req.extra?.name ?? "USD Coin", version: req.extra?.version ?? "2", chainId, verifyingContract: req.asset },
39
+ types: { TransferWithAuthorization: [
40
+ { name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" },
41
+ { name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" }] },
42
+ primaryType: "TransferWithAuthorization",
43
+ message: { ...authorization, value: BigInt(authorization.value), validAfter: BigInt(authorization.validAfter), validBefore: BigInt(authorization.validBefore) },
44
+ });
45
+ return b64(JSON.stringify({ x402Version: 1, scheme: "exact", network: req.network, payload: { signature, authorization } }));
46
+ }
47
+
48
+ /** GET `url`, paying once if it answers 402. Returns { status, body, price, receipt }. */
49
+ export async function payAndFetch(url, { privateKey, account, maxPrice, fetchImpl = fetch } = {}) {
50
+ const first = await fetchImpl(url);
51
+ if (first.status !== 402) return { status: first.status, body: await first.text(), price: null, receipt: null };
52
+ const offer = await first.json();
53
+ const req = choose(offer.accepts, { maxPrice });
54
+ if (!account) {
55
+ if (!privateKey) throw new Error(`${url} costs ${req.maxAmountRequired} (base units of ${req.asset} on ${req.network}); no wallet configured to pay`);
56
+ account = (await import("viem/accounts")).privateKeyToAccount(privateKey);
57
+ }
58
+ const paid = await fetchImpl(url, { headers: { "X-PAYMENT": await paymentHeader(req, account) } });
59
+ const receiptHeader = paid.headers.get("X-PAYMENT-RESPONSE");
60
+ const body = await paid.text();
61
+ if (paid.status === 402) throw new Error(`payment refused: ${JSON.parse(body).error ?? body}`);
62
+ return { status: paid.status, body, price: req.maxAmountRequired, receipt: receiptHeader ? JSON.parse(unb64(receiptHeader)) : null };
63
+ }
64
+
65
+ // ── self-check: `node consume/x402.js` — a stand-in seller that checks the signature ──
66
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/x402.js")) {
67
+ const { privateKeyToAccount, generatePrivateKey } = await import("viem/accounts");
68
+ const { verifyTypedData } = await import("viem");
69
+ const buyer = privateKeyToAccount(generatePrivateKey());
70
+ const USDC = "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", SELLER = `0x${"5e".repeat(20)}`;
71
+ const req = { scheme: "exact", network: "arbitrum-sepolia", maxAmountRequired: "10000", resource: "https://x/paid/1",
72
+ payTo: SELLER, asset: USDC, maxTimeoutSeconds: 120, extra: { name: "USD Coin", version: "2" } };
73
+ let settled = null;
74
+ const seller = async (url, init = {}) => {
75
+ const h = init.headers?.["X-PAYMENT"];
76
+ if (!h) return new Response(JSON.stringify({ x402Version: 1, accepts: [req] }), { status: 402 });
77
+ const p = JSON.parse(unb64(h)).payload, a = p.authorization;
78
+ const ok = await verifyTypedData({ address: a.from, signature: p.signature,
79
+ domain: { name: "USD Coin", version: "2", chainId: 421614, verifyingContract: USDC },
80
+ types: { TransferWithAuthorization: [{ name: "from", type: "address" }, { name: "to", type: "address" }, { name: "value", type: "uint256" },
81
+ { name: "validAfter", type: "uint256" }, { name: "validBefore", type: "uint256" }, { name: "nonce", type: "bytes32" }] },
82
+ primaryType: "TransferWithAuthorization",
83
+ message: { ...a, value: BigInt(a.value), validAfter: BigInt(a.validAfter), validBefore: BigInt(a.validBefore) } });
84
+ if (!ok || a.to !== SELLER || a.value !== "10000") return new Response(JSON.stringify({ error: "bad payment" }), { status: 402 });
85
+ settled = a;
86
+ return new Response('{"secret":42}', { status: 200, headers: { "X-PAYMENT-RESPONSE": b64(JSON.stringify({ success: true, transaction: "0xabc" })) } });
87
+ };
88
+ const r = await payAndFetch("https://x/paid/1", { account: buyer, maxPrice: 10000n, fetchImpl: seller });
89
+ if (r.body !== '{"secret":42}' || r.receipt.transaction !== "0xabc" || settled.from !== buyer.address) throw new Error(`paid fetch: ${JSON.stringify(r)}`);
90
+ let refused = false;
91
+ try { await payAndFetch("https://x/paid/1", { account: buyer, maxPrice: 9999n, fetchImpl: seller }); } catch (e) { refused = /exceeds the cap/.test(e.message); }
92
+ if (!refused) throw new Error("a price over the cap must be refused before signing");
93
+ console.log("x402.js self-check ok — a signed EIP-3009 authorization the seller can verify, exactly the asked amount to the asked payee, the receipt read back, and a price over the cap refused before signing");
94
+ }