@orangecheck/chat-core 0.1.0 → 0.2.0

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.
package/dist/io.d.mts CHANGED
@@ -1,26 +1,25 @@
1
- import { t as VerifyOnChain, E as WriteProof } from './channel-D8aOo4eI.mjs';
1
+ import { u as VerifyOnChain, F as WriteProof } from './channel-ChK3AAvd.mjs';
2
2
  import '@orangecheck/lock-device';
3
3
 
4
- interface MempoolUtxo {
5
- txid: string;
6
- vout: number;
7
- value?: number;
8
- status?: {
9
- confirmed?: boolean;
10
- block_height?: number;
11
- block_hash?: string;
12
- };
4
+ interface OutpointRead {
5
+ address: string | null;
6
+ value: number | null;
7
+ confirmed: boolean;
8
+ height: number | null;
9
+ hash: string | null;
10
+ spent: boolean;
13
11
  }
14
- type WriteProofUtxoCache = Map<string, Promise<MempoolUtxo[] | null>>;
12
+ type WriteProofCache = Map<string, Promise<OutpointRead | 'missing' | 'unanswered'>>;
15
13
  interface EsploraOptions {
16
14
  baseUrl?: string;
17
15
  fetch?: typeof fetch;
18
- utxoCache?: WriteProofUtxoCache;
16
+ cache?: WriteProofCache;
19
17
  }
18
+ declare function readOutpoint(outpoint: string, opts?: EsploraOptions): Promise<OutpointRead | 'missing' | 'unanswered'>;
20
19
  declare function writeProofOnChain(proof: WriteProof, authorAddress: string, opts?: EsploraOptions): Promise<boolean | 'no-answer'>;
21
20
  declare function verifyWriteProofOnChain(proof: WriteProof, authorAddress: string, opts?: EsploraOptions): Promise<boolean>;
22
21
  declare function esploraWriteProofCheck(opts?: EsploraOptions & {
23
- unanswered?: 'false' | 'throw';
22
+ unanswered?: 'throw' | 'false';
24
23
  }): VerifyOnChain;
25
24
 
26
- export { type EsploraOptions, type MempoolUtxo, type WriteProofUtxoCache, esploraWriteProofCheck, verifyWriteProofOnChain, writeProofOnChain };
25
+ export { type EsploraOptions, type OutpointRead, type WriteProofCache, esploraWriteProofCheck, readOutpoint, verifyWriteProofOnChain, writeProofOnChain };
package/dist/io.d.ts CHANGED
@@ -1,26 +1,25 @@
1
- import { t as VerifyOnChain, E as WriteProof } from './channel-D8aOo4eI.js';
1
+ import { u as VerifyOnChain, F as WriteProof } from './channel-ChK3AAvd.js';
2
2
  import '@orangecheck/lock-device';
3
3
 
4
- interface MempoolUtxo {
5
- txid: string;
6
- vout: number;
7
- value?: number;
8
- status?: {
9
- confirmed?: boolean;
10
- block_height?: number;
11
- block_hash?: string;
12
- };
4
+ interface OutpointRead {
5
+ address: string | null;
6
+ value: number | null;
7
+ confirmed: boolean;
8
+ height: number | null;
9
+ hash: string | null;
10
+ spent: boolean;
13
11
  }
14
- type WriteProofUtxoCache = Map<string, Promise<MempoolUtxo[] | null>>;
12
+ type WriteProofCache = Map<string, Promise<OutpointRead | 'missing' | 'unanswered'>>;
15
13
  interface EsploraOptions {
16
14
  baseUrl?: string;
17
15
  fetch?: typeof fetch;
18
- utxoCache?: WriteProofUtxoCache;
16
+ cache?: WriteProofCache;
19
17
  }
18
+ declare function readOutpoint(outpoint: string, opts?: EsploraOptions): Promise<OutpointRead | 'missing' | 'unanswered'>;
20
19
  declare function writeProofOnChain(proof: WriteProof, authorAddress: string, opts?: EsploraOptions): Promise<boolean | 'no-answer'>;
21
20
  declare function verifyWriteProofOnChain(proof: WriteProof, authorAddress: string, opts?: EsploraOptions): Promise<boolean>;
22
21
  declare function esploraWriteProofCheck(opts?: EsploraOptions & {
23
- unanswered?: 'false' | 'throw';
22
+ unanswered?: 'throw' | 'false';
24
23
  }): VerifyOnChain;
25
24
 
26
- export { type EsploraOptions, type MempoolUtxo, type WriteProofUtxoCache, esploraWriteProofCheck, verifyWriteProofOnChain, writeProofOnChain };
25
+ export { type EsploraOptions, type OutpointRead, type WriteProofCache, esploraWriteProofCheck, readOutpoint, verifyWriteProofOnChain, writeProofOnChain };
package/dist/io.js CHANGED
@@ -1,52 +1,74 @@
1
1
  'use strict';
2
2
 
3
3
  var chunkGFFQSF75_js = require('./chunk-GFFQSF75.js');
4
+ var bip322 = require('@orangecheck/bip322');
4
5
 
5
- // src/io.ts
6
- async function addressUtxos(address, base, f, cache) {
7
- const hit = cache?.get(address);
6
+ async function readOutpoint(outpoint, opts = {}) {
7
+ const hit = opts.cache?.get(outpoint);
8
8
  if (hit) return hit;
9
+ const base = (opts.baseUrl ?? "https://mempool.space/api").replace(/\/+$/, "");
10
+ const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
9
11
  const p = (async () => {
12
+ const sep = outpoint.lastIndexOf(":");
13
+ const txid = outpoint.slice(0, sep);
14
+ const vout = Number(outpoint.slice(sep + 1));
15
+ if (sep <= 0 || !/^[0-9a-f]{64}$/i.test(txid) || !Number.isInteger(vout) || vout < 0) {
16
+ return "missing";
17
+ }
10
18
  try {
11
- const res = await f(`${base}/address/${encodeURIComponent(address)}/utxo`);
12
- if (!res.ok) return null;
13
- return await res.json();
19
+ const txRes = await f(`${base}/tx/${txid}`);
20
+ if (txRes.status === 404) return "missing";
21
+ if (!txRes.ok) return "unanswered";
22
+ const tx = await txRes.json();
23
+ const out = tx.vout?.[vout];
24
+ if (!out) return "missing";
25
+ const spendRes = await f(`${base}/tx/${txid}/outspend/${vout}`);
26
+ if (spendRes.status === 404) return "missing";
27
+ if (!spendRes.ok) return "unanswered";
28
+ const spend = await spendRes.json();
29
+ if (typeof spend.spent !== "boolean") return "unanswered";
30
+ return {
31
+ address: out.scriptpubkey_address ?? null,
32
+ value: typeof out.value === "number" ? out.value : null,
33
+ confirmed: tx.status?.confirmed === true,
34
+ height: tx.status?.block_height ?? null,
35
+ hash: tx.status?.block_hash ?? null,
36
+ spent: spend.spent
37
+ };
14
38
  } catch {
15
- return null;
39
+ return "unanswered";
16
40
  }
17
41
  })();
18
- cache?.set(address, p);
42
+ opts.cache?.set(outpoint, p);
43
+ void p.then((r) => {
44
+ if (r === "unanswered" && opts.cache?.get(outpoint) === p) opts.cache.delete(outpoint);
45
+ });
19
46
  return p;
20
47
  }
21
48
  async function writeProofOnChain(proof, authorAddress, opts = {}) {
22
- const base = (opts.baseUrl ?? "https://mempool.space/api").replace(/\/+$/, "");
23
- const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
24
- const utxos = await addressUtxos(authorAddress, base, f, opts.utxoCache);
25
- if (!utxos) return "no-answer";
26
- const sep = proof.outpoint.lastIndexOf(":");
27
- if (sep <= 0) return false;
28
- const txid = proof.outpoint.slice(0, sep);
29
- const vout = Number.parseInt(proof.outpoint.slice(sep + 1), 10);
30
- return utxos.some(
31
- (u) => u.txid === txid && u.vout === vout && u.status?.confirmed === true && u.value === proof.value_sats && u.status?.block_height === proof.anchor_block_height && u.status?.block_hash === proof.anchor_block_hash
32
- );
49
+ const coin = await readOutpoint(proof.outpoint, opts);
50
+ if (coin === "unanswered") return "no-answer";
51
+ if (coin === "missing") return false;
52
+ const owner = coin.address === null ? null : bip322.normalizeAddress(coin.address);
53
+ return owner !== null && owner === bip322.normalizeAddress(authorAddress) && coin.value === proof.value_sats && coin.confirmed && coin.height === proof.anchor_block_height && coin.hash === proof.anchor_block_hash && !coin.spent;
33
54
  }
34
55
  async function verifyWriteProofOnChain(proof, authorAddress, opts = {}) {
35
56
  return await writeProofOnChain(proof, authorAddress, opts) === true;
36
57
  }
37
58
  function esploraWriteProofCheck(opts = {}) {
38
- const utxoCache = opts.utxoCache ?? /* @__PURE__ */ new Map();
59
+ const cache = opts.cache ?? /* @__PURE__ */ new Map();
39
60
  return async (proof, address) => {
40
- const r = await writeProofOnChain(proof, address, { ...opts, utxoCache });
61
+ const r = await writeProofOnChain(proof, address, { ...opts, cache });
41
62
  if (r === "no-answer") {
42
- if (opts.unanswered === "throw") throw new chunkGFFQSF75_js.UnansweredReadError("the chain could not be read");
43
- return false;
63
+ if (opts.unanswered === "false") return false;
64
+ throw new chunkGFFQSF75_js.UnansweredReadError("the chain could not be read");
44
65
  }
45
66
  return r;
46
67
  };
47
68
  }
48
69
 
49
70
  exports.esploraWriteProofCheck = esploraWriteProofCheck;
71
+ exports.readOutpoint = readOutpoint;
50
72
  exports.verifyWriteProofOnChain = verifyWriteProofOnChain;
51
73
  exports.writeProofOnChain = writeProofOnChain;
52
74
  //# sourceMappingURL=io.js.map
package/dist/io.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/io.ts"],"names":["UnansweredReadError"],"mappings":";;;;;AAyBA,eAAe,YAAA,CACX,OAAA,EACA,IAAA,EACA,CAAA,EACA,KAAA,EAC6B;AAC7B,EAAA,MAAM,GAAA,GAAM,KAAA,EAAO,GAAA,CAAI,OAAO,CAAA;AAC9B,EAAA,IAAI,KAAK,OAAO,GAAA;AAChB,EAAA,MAAM,KAAK,YAAY;AACnB,IAAA,IAAI;AACA,MAAA,MAAM,GAAA,GAAM,MAAM,CAAA,CAAE,CAAA,EAAG,IAAI,CAAA,SAAA,EAAY,kBAAA,CAAmB,OAAO,CAAC,CAAA,KAAA,CAAO,CAAA;AACzE,MAAA,IAAI,CAAC,GAAA,CAAI,EAAA,EAAI,OAAO,IAAA;AACpB,MAAA,OAAQ,MAAM,IAAI,IAAA,EAAK;AAAA,IAC3B,CAAA,CAAA,MAAQ;AACJ,MAAA,OAAO,IAAA;AAAA,IACX;AAAA,EACJ,CAAA,GAAG;AACH,EAAA,KAAA,EAAO,GAAA,CAAI,SAAS,CAAC,CAAA;AACrB,EAAA,OAAO,CAAA;AACX;AAQA,eAAsB,iBAAA,CAClB,KAAA,EACA,aAAA,EACA,IAAA,GAAuB,EAAC,EACM;AAC9B,EAAA,MAAM,QAAQ,IAAA,CAAK,OAAA,IAAW,2BAAA,EAA6B,OAAA,CAAQ,QAAQ,EAAE,CAAA;AAC7E,EAAA,MAAM,IAAI,IAAA,CAAK,KAAA,IAAS,UAAA,CAAW,KAAA,CAAM,KAAK,UAAU,CAAA;AACxD,EAAA,MAAM,QAAQ,MAAM,YAAA,CAAa,eAAe,IAAA,EAAM,CAAA,EAAG,KAAK,SAAS,CAAA;AACvE,EAAA,IAAI,CAAC,OAAO,OAAO,WAAA;AACnB,EAAA,MAAM,GAAA,GAAM,KAAA,CAAM,QAAA,CAAS,WAAA,CAAY,GAAG,CAAA;AAC1C,EAAA,IAAI,GAAA,IAAO,GAAG,OAAO,KAAA;AACrB,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,QAAA,CAAS,KAAA,CAAM,GAAG,GAAG,CAAA;AACxC,EAAA,MAAM,IAAA,GAAO,OAAO,QAAA,CAAS,KAAA,CAAM,SAAS,KAAA,CAAM,GAAA,GAAM,CAAC,CAAA,EAAG,EAAE,CAAA;AAC9D,EAAA,OAAO,KAAA,CAAM,IAAA;AAAA,IACT,CAAC,CAAA,KACG,CAAA,CAAE,IAAA,KAAS,IAAA,IACX,EAAE,IAAA,KAAS,IAAA,IACX,CAAA,CAAE,MAAA,EAAQ,SAAA,KAAc,IAAA,IACxB,EAAE,KAAA,KAAU,KAAA,CAAM,UAAA,IAClB,CAAA,CAAE,MAAA,EAAQ,YAAA,KAAiB,MAAM,mBAAA,IACjC,CAAA,CAAE,MAAA,EAAQ,UAAA,KAAe,KAAA,CAAM;AAAA,GACvC;AACJ;AAGA,eAAsB,uBAAA,CAClB,KAAA,EACA,aAAA,EACA,IAAA,GAAuB,EAAC,EACR;AAChB,EAAA,OAAQ,MAAM,iBAAA,CAAkB,KAAA,EAAO,aAAA,EAAe,IAAI,CAAA,KAAO,IAAA;AACrE;AAOO,SAAS,sBAAA,CACZ,IAAA,GAA4D,EAAC,EAChD;AACb,EAAA,MAAM,SAAA,GAAY,IAAA,CAAK,SAAA,oBAAa,IAAI,GAAA,EAAI;AAC5C,EAAA,OAAO,OAAO,OAAO,OAAA,KAAY;AAC7B,IAAA,MAAM,CAAA,GAAI,MAAM,iBAAA,CAAkB,KAAA,EAAO,SAAS,EAAE,GAAG,IAAA,EAAM,SAAA,EAAW,CAAA;AACxE,IAAA,IAAI,MAAM,WAAA,EAAa;AACnB,MAAA,IAAI,KAAK,UAAA,KAAe,OAAA,EAAS,MAAM,IAAIA,qCAAoB,6BAA6B,CAAA;AAC5F,MAAA,OAAO,KAAA;AAAA,IACX;AACA,IAAA,OAAO,CAAA;AAAA,EACX,CAAA;AACJ","file":"io.js","sourcesContent":["// Readers over a public Esplora API (mempool.space by default) for the checks\n// chat-core leaves to its caller. Public chain data only.\n\nimport type { VerifyOnChain, WriteProof } from './channel';\nimport { UnansweredReadError } from './read';\n\n/** One output from Esplora's /address/<addr>/utxo. */\nexport interface MempoolUtxo {\n txid: string;\n vout: number;\n value?: number;\n status?: { confirmed?: boolean; block_height?: number; block_hash?: string };\n}\n\n/** One address→UTXO-set read per distinct author for a batch of posts. */\nexport type WriteProofUtxoCache = Map<string, Promise<MempoolUtxo[] | null>>;\n\nexport interface EsploraOptions {\n /** Esplora API base; default https://mempool.space/api. */\n baseUrl?: string;\n fetch?: typeof fetch;\n /** Share one cache across the posts of a feed. */\n utxoCache?: WriteProofUtxoCache;\n}\n\nasync function addressUtxos(\n address: string,\n base: string,\n f: typeof fetch,\n cache?: WriteProofUtxoCache\n): Promise<MempoolUtxo[] | null> {\n const hit = cache?.get(address);\n if (hit) return hit;\n const p = (async () => {\n try {\n const res = await f(`${base}/address/${encodeURIComponent(address)}/utxo`);\n if (!res.ok) return null;\n return (await res.json()) as MempoolUtxo[];\n } catch {\n return null;\n }\n })();\n cache?.set(address, p);\n return p;\n}\n\n/**\n * Does a utxo-floor write proof match current chain state (§8.3.3), the\n * Bitcoin-load-bearing leg: the proof's outpoint is an unspent output of the\n * author's address with exactly the value, confirmation height and block hash\n * the proof states? 'no-answer' when the address's outputs could not be read.\n */\nexport async function writeProofOnChain(\n proof: WriteProof,\n authorAddress: string,\n opts: EsploraOptions = {}\n): Promise<boolean | 'no-answer'> {\n const base = (opts.baseUrl ?? 'https://mempool.space/api').replace(/\\/+$/, '');\n const f = opts.fetch ?? globalThis.fetch.bind(globalThis);\n const utxos = await addressUtxos(authorAddress, base, f, opts.utxoCache);\n if (!utxos) return 'no-answer';\n const sep = proof.outpoint.lastIndexOf(':');\n if (sep <= 0) return false;\n const txid = proof.outpoint.slice(0, sep);\n const vout = Number.parseInt(proof.outpoint.slice(sep + 1), 10);\n return utxos.some(\n (u) =>\n u.txid === txid &&\n u.vout === vout &&\n u.status?.confirmed === true &&\n u.value === proof.value_sats &&\n u.status?.block_height === proof.anchor_block_height &&\n u.status?.block_hash === proof.anchor_block_hash\n );\n}\n\n/** writeProofOnChain, fail-closed: an unanswered read is false, so the post does not render. */\nexport async function verifyWriteProofOnChain(\n proof: WriteProof,\n authorAddress: string,\n opts: EsploraOptions = {}\n): Promise<boolean> {\n return (await writeProofOnChain(proof, authorAddress, opts)) === true;\n}\n\n/**\n * A VerifyOnChain over Esplora, with one cache for the batch it is made for. An\n * unanswered read is false (a feed hides the post), or with `unanswered: 'throw'`\n * an UnansweredReadError, for a verifier that must say it could not tell.\n */\nexport function esploraWriteProofCheck(\n opts: EsploraOptions & { unanswered?: 'false' | 'throw' } = {}\n): VerifyOnChain {\n const utxoCache = opts.utxoCache ?? new Map();\n return async (proof, address) => {\n const r = await writeProofOnChain(proof, address, { ...opts, utxoCache });\n if (r === 'no-answer') {\n if (opts.unanswered === 'throw') throw new UnansweredReadError('the chain could not be read');\n return false;\n }\n return r;\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/io.ts"],"names":["normalizeAddress","UnansweredReadError"],"mappings":"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","file":"io.js","sourcesContent":["// Readers over a public Esplora API (mempool.space by default) for the checks\n// chat-core leaves to its caller. Public chain data only.\n\nimport { normalizeAddress } from '@orangecheck/bip322';\n\nimport type { VerifyOnChain, WriteProof } from './channel';\nimport { UnansweredReadError } from './read';\n\n/** The coin a write proof names, as the explorer holds it. */\nexport interface OutpointRead {\n /** The output's scriptpubkey_address; null for a script with none. */\n address: string | null;\n value: number | null;\n confirmed: boolean;\n height: number | null;\n hash: string | null;\n /** Spent, a spend still in the mempool included. */\n spent: boolean;\n}\n\n/**\n * One read per outpoint for a batch of posts: two posts naming one coin read it\n * once. A read that got no answer is not kept, so the next asks again.\n */\nexport type WriteProofCache = Map<string, Promise<OutpointRead | 'missing' | 'unanswered'>>;\n\nexport interface EsploraOptions {\n /** Esplora API base; default https://mempool.space/api. */\n baseUrl?: string;\n fetch?: typeof fetch;\n /** Share one cache across the posts of a feed. */\n cache?: WriteProofCache;\n}\n\n/**\n * The one coin a write proof names (§8.3.3 (1)): GET /tx/<txid> for the output\n * at vout, then GET /tx/<txid>/outspend/<vout>. A 404 from either, or a vout the\n * transaction does not have, is 'missing'. A 429, a 5xx, a timeout or a body\n * that does not parse is 'unanswered'. Reading the coin rather than the\n * address's whole UTXO set holds for any wallet: Esplora refuses to list an\n * address past a few hundred UTXOs, and anyone can send an author dust.\n */\nexport async function readOutpoint(\n outpoint: string,\n opts: EsploraOptions = {}\n): Promise<OutpointRead | 'missing' | 'unanswered'> {\n const hit = opts.cache?.get(outpoint);\n if (hit) return hit;\n const base = (opts.baseUrl ?? 'https://mempool.space/api').replace(/\\/+$/, '');\n const f = opts.fetch ?? globalThis.fetch.bind(globalThis);\n const p = (async (): Promise<OutpointRead | 'missing' | 'unanswered'> => {\n const sep = outpoint.lastIndexOf(':');\n const txid = outpoint.slice(0, sep);\n const vout = Number(outpoint.slice(sep + 1));\n if (sep <= 0 || !/^[0-9a-f]{64}$/i.test(txid) || !Number.isInteger(vout) || vout < 0) {\n return 'missing';\n }\n try {\n const txRes = await f(`${base}/tx/${txid}`);\n if (txRes.status === 404) return 'missing';\n if (!txRes.ok) return 'unanswered';\n const tx = (await txRes.json()) as {\n vout?: { scriptpubkey_address?: string; value?: number }[];\n status?: { confirmed?: boolean; block_height?: number; block_hash?: string };\n };\n const out = tx.vout?.[vout];\n if (!out) return 'missing';\n const spendRes = await f(`${base}/tx/${txid}/outspend/${vout}`);\n if (spendRes.status === 404) return 'missing';\n if (!spendRes.ok) return 'unanswered';\n const spend = (await spendRes.json()) as { spent?: unknown };\n if (typeof spend.spent !== 'boolean') return 'unanswered';\n return {\n address: out.scriptpubkey_address ?? null,\n value: typeof out.value === 'number' ? out.value : null,\n confirmed: tx.status?.confirmed === true,\n height: tx.status?.block_height ?? null,\n hash: tx.status?.block_hash ?? null,\n spent: spend.spent,\n };\n } catch {\n return 'unanswered';\n }\n })();\n opts.cache?.set(outpoint, p);\n void p.then((r) => {\n if (r === 'unanswered' && opts.cache?.get(outpoint) === p) opts.cache.delete(outpoint);\n });\n return p;\n}\n\n/**\n * Does a utxo-floor write proof match current chain state (§8.3.3 (1)), the\n * Bitcoin-load-bearing leg: the proof's outpoint pays the author's address\n * (both in normal form, so a bech32 address in capitals is the same address)\n * exactly the value, confirmed at the height and block hash the proof states,\n * and is unspent (a spend still in the mempool counts as spent)? 'no-answer'\n * when the explorer did not answer; false when the coin is missing or differs.\n */\nexport async function writeProofOnChain(\n proof: WriteProof,\n authorAddress: string,\n opts: EsploraOptions = {}\n): Promise<boolean | 'no-answer'> {\n const coin = await readOutpoint(proof.outpoint, opts);\n if (coin === 'unanswered') return 'no-answer';\n if (coin === 'missing') return false;\n const owner = coin.address === null ? null : normalizeAddress(coin.address);\n return (\n owner !== null &&\n owner === normalizeAddress(authorAddress) &&\n coin.value === proof.value_sats &&\n coin.confirmed &&\n coin.height === proof.anchor_block_height &&\n coin.hash === proof.anchor_block_hash &&\n !coin.spent\n );\n}\n\n/** writeProofOnChain, fail-closed: an unanswered read is false, so the post does not render. */\nexport async function verifyWriteProofOnChain(\n proof: WriteProof,\n authorAddress: string,\n opts: EsploraOptions = {}\n): Promise<boolean> {\n return (await writeProofOnChain(proof, authorAddress, opts)) === true;\n}\n\n/**\n * A VerifyOnChain over Esplora, with one cache for the batch it is made for. A\n * read that gets no answer throws UnansweredReadError, so a verifier says it\n * could not tell rather than read the silence as \"no\" (verifyChannelPosts\n * counts the post unchecked). `unanswered: 'false'` makes it false instead, for\n * a caller that only needs fail-closed.\n */\nexport function esploraWriteProofCheck(\n opts: EsploraOptions & { unanswered?: 'throw' | 'false' } = {}\n): VerifyOnChain {\n const cache = opts.cache ?? new Map();\n return async (proof, address) => {\n const r = await writeProofOnChain(proof, address, { ...opts, cache });\n if (r === 'no-answer') {\n if (opts.unanswered === 'false') return false;\n throw new UnansweredReadError('the chain could not be read');\n }\n return r;\n };\n}\n"]}
package/dist/io.mjs CHANGED
@@ -1,49 +1,70 @@
1
1
  import { UnansweredReadError } from './chunk-6NJU4VWR.mjs';
2
+ import { normalizeAddress } from '@orangecheck/bip322';
2
3
 
3
- // src/io.ts
4
- async function addressUtxos(address, base, f, cache) {
5
- const hit = cache?.get(address);
4
+ async function readOutpoint(outpoint, opts = {}) {
5
+ const hit = opts.cache?.get(outpoint);
6
6
  if (hit) return hit;
7
+ const base = (opts.baseUrl ?? "https://mempool.space/api").replace(/\/+$/, "");
8
+ const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
7
9
  const p = (async () => {
10
+ const sep = outpoint.lastIndexOf(":");
11
+ const txid = outpoint.slice(0, sep);
12
+ const vout = Number(outpoint.slice(sep + 1));
13
+ if (sep <= 0 || !/^[0-9a-f]{64}$/i.test(txid) || !Number.isInteger(vout) || vout < 0) {
14
+ return "missing";
15
+ }
8
16
  try {
9
- const res = await f(`${base}/address/${encodeURIComponent(address)}/utxo`);
10
- if (!res.ok) return null;
11
- return await res.json();
17
+ const txRes = await f(`${base}/tx/${txid}`);
18
+ if (txRes.status === 404) return "missing";
19
+ if (!txRes.ok) return "unanswered";
20
+ const tx = await txRes.json();
21
+ const out = tx.vout?.[vout];
22
+ if (!out) return "missing";
23
+ const spendRes = await f(`${base}/tx/${txid}/outspend/${vout}`);
24
+ if (spendRes.status === 404) return "missing";
25
+ if (!spendRes.ok) return "unanswered";
26
+ const spend = await spendRes.json();
27
+ if (typeof spend.spent !== "boolean") return "unanswered";
28
+ return {
29
+ address: out.scriptpubkey_address ?? null,
30
+ value: typeof out.value === "number" ? out.value : null,
31
+ confirmed: tx.status?.confirmed === true,
32
+ height: tx.status?.block_height ?? null,
33
+ hash: tx.status?.block_hash ?? null,
34
+ spent: spend.spent
35
+ };
12
36
  } catch {
13
- return null;
37
+ return "unanswered";
14
38
  }
15
39
  })();
16
- cache?.set(address, p);
40
+ opts.cache?.set(outpoint, p);
41
+ void p.then((r) => {
42
+ if (r === "unanswered" && opts.cache?.get(outpoint) === p) opts.cache.delete(outpoint);
43
+ });
17
44
  return p;
18
45
  }
19
46
  async function writeProofOnChain(proof, authorAddress, opts = {}) {
20
- const base = (opts.baseUrl ?? "https://mempool.space/api").replace(/\/+$/, "");
21
- const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
22
- const utxos = await addressUtxos(authorAddress, base, f, opts.utxoCache);
23
- if (!utxos) return "no-answer";
24
- const sep = proof.outpoint.lastIndexOf(":");
25
- if (sep <= 0) return false;
26
- const txid = proof.outpoint.slice(0, sep);
27
- const vout = Number.parseInt(proof.outpoint.slice(sep + 1), 10);
28
- return utxos.some(
29
- (u) => u.txid === txid && u.vout === vout && u.status?.confirmed === true && u.value === proof.value_sats && u.status?.block_height === proof.anchor_block_height && u.status?.block_hash === proof.anchor_block_hash
30
- );
47
+ const coin = await readOutpoint(proof.outpoint, opts);
48
+ if (coin === "unanswered") return "no-answer";
49
+ if (coin === "missing") return false;
50
+ const owner = coin.address === null ? null : normalizeAddress(coin.address);
51
+ return owner !== null && owner === normalizeAddress(authorAddress) && coin.value === proof.value_sats && coin.confirmed && coin.height === proof.anchor_block_height && coin.hash === proof.anchor_block_hash && !coin.spent;
31
52
  }
32
53
  async function verifyWriteProofOnChain(proof, authorAddress, opts = {}) {
33
54
  return await writeProofOnChain(proof, authorAddress, opts) === true;
34
55
  }
35
56
  function esploraWriteProofCheck(opts = {}) {
36
- const utxoCache = opts.utxoCache ?? /* @__PURE__ */ new Map();
57
+ const cache = opts.cache ?? /* @__PURE__ */ new Map();
37
58
  return async (proof, address) => {
38
- const r = await writeProofOnChain(proof, address, { ...opts, utxoCache });
59
+ const r = await writeProofOnChain(proof, address, { ...opts, cache });
39
60
  if (r === "no-answer") {
40
- if (opts.unanswered === "throw") throw new UnansweredReadError("the chain could not be read");
41
- return false;
61
+ if (opts.unanswered === "false") return false;
62
+ throw new UnansweredReadError("the chain could not be read");
42
63
  }
43
64
  return r;
44
65
  };
45
66
  }
46
67
 
47
- export { esploraWriteProofCheck, verifyWriteProofOnChain, writeProofOnChain };
68
+ export { esploraWriteProofCheck, readOutpoint, verifyWriteProofOnChain, writeProofOnChain };
48
69
  //# sourceMappingURL=io.mjs.map
49
70
  //# sourceMappingURL=io.mjs.map
package/dist/io.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/io.ts"],"names":[],"mappings":";;;AAyBA,eAAe,YAAA,CACX,OAAA,EACA,IAAA,EACA,CAAA,EACA,KAAA,EAC6B;AAC7B,EAAA,MAAM,GAAA,GAAM,KAAA,EAAO,GAAA,CAAI,OAAO,CAAA;AAC9B,EAAA,IAAI,KAAK,OAAO,GAAA;AAChB,EAAA,MAAM,KAAK,YAAY;AACnB,IAAA,IAAI;AACA,MAAA,MAAM,GAAA,GAAM,MAAM,CAAA,CAAE,CAAA,EAAG,IAAI,CAAA,SAAA,EAAY,kBAAA,CAAmB,OAAO,CAAC,CAAA,KAAA,CAAO,CAAA;AACzE,MAAA,IAAI,CAAC,GAAA,CAAI,EAAA,EAAI,OAAO,IAAA;AACpB,MAAA,OAAQ,MAAM,IAAI,IAAA,EAAK;AAAA,IAC3B,CAAA,CAAA,MAAQ;AACJ,MAAA,OAAO,IAAA;AAAA,IACX;AAAA,EACJ,CAAA,GAAG;AACH,EAAA,KAAA,EAAO,GAAA,CAAI,SAAS,CAAC,CAAA;AACrB,EAAA,OAAO,CAAA;AACX;AAQA,eAAsB,iBAAA,CAClB,KAAA,EACA,aAAA,EACA,IAAA,GAAuB,EAAC,EACM;AAC9B,EAAA,MAAM,QAAQ,IAAA,CAAK,OAAA,IAAW,2BAAA,EAA6B,OAAA,CAAQ,QAAQ,EAAE,CAAA;AAC7E,EAAA,MAAM,IAAI,IAAA,CAAK,KAAA,IAAS,UAAA,CAAW,KAAA,CAAM,KAAK,UAAU,CAAA;AACxD,EAAA,MAAM,QAAQ,MAAM,YAAA,CAAa,eAAe,IAAA,EAAM,CAAA,EAAG,KAAK,SAAS,CAAA;AACvE,EAAA,IAAI,CAAC,OAAO,OAAO,WAAA;AACnB,EAAA,MAAM,GAAA,GAAM,KAAA,CAAM,QAAA,CAAS,WAAA,CAAY,GAAG,CAAA;AAC1C,EAAA,IAAI,GAAA,IAAO,GAAG,OAAO,KAAA;AACrB,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,QAAA,CAAS,KAAA,CAAM,GAAG,GAAG,CAAA;AACxC,EAAA,MAAM,IAAA,GAAO,OAAO,QAAA,CAAS,KAAA,CAAM,SAAS,KAAA,CAAM,GAAA,GAAM,CAAC,CAAA,EAAG,EAAE,CAAA;AAC9D,EAAA,OAAO,KAAA,CAAM,IAAA;AAAA,IACT,CAAC,CAAA,KACG,CAAA,CAAE,IAAA,KAAS,IAAA,IACX,EAAE,IAAA,KAAS,IAAA,IACX,CAAA,CAAE,MAAA,EAAQ,SAAA,KAAc,IAAA,IACxB,EAAE,KAAA,KAAU,KAAA,CAAM,UAAA,IAClB,CAAA,CAAE,MAAA,EAAQ,YAAA,KAAiB,MAAM,mBAAA,IACjC,CAAA,CAAE,MAAA,EAAQ,UAAA,KAAe,KAAA,CAAM;AAAA,GACvC;AACJ;AAGA,eAAsB,uBAAA,CAClB,KAAA,EACA,aAAA,EACA,IAAA,GAAuB,EAAC,EACR;AAChB,EAAA,OAAQ,MAAM,iBAAA,CAAkB,KAAA,EAAO,aAAA,EAAe,IAAI,CAAA,KAAO,IAAA;AACrE;AAOO,SAAS,sBAAA,CACZ,IAAA,GAA4D,EAAC,EAChD;AACb,EAAA,MAAM,SAAA,GAAY,IAAA,CAAK,SAAA,oBAAa,IAAI,GAAA,EAAI;AAC5C,EAAA,OAAO,OAAO,OAAO,OAAA,KAAY;AAC7B,IAAA,MAAM,CAAA,GAAI,MAAM,iBAAA,CAAkB,KAAA,EAAO,SAAS,EAAE,GAAG,IAAA,EAAM,SAAA,EAAW,CAAA;AACxE,IAAA,IAAI,MAAM,WAAA,EAAa;AACnB,MAAA,IAAI,KAAK,UAAA,KAAe,OAAA,EAAS,MAAM,IAAI,oBAAoB,6BAA6B,CAAA;AAC5F,MAAA,OAAO,KAAA;AAAA,IACX;AACA,IAAA,OAAO,CAAA;AAAA,EACX,CAAA;AACJ","file":"io.mjs","sourcesContent":["// Readers over a public Esplora API (mempool.space by default) for the checks\n// chat-core leaves to its caller. Public chain data only.\n\nimport type { VerifyOnChain, WriteProof } from './channel';\nimport { UnansweredReadError } from './read';\n\n/** One output from Esplora's /address/<addr>/utxo. */\nexport interface MempoolUtxo {\n txid: string;\n vout: number;\n value?: number;\n status?: { confirmed?: boolean; block_height?: number; block_hash?: string };\n}\n\n/** One address→UTXO-set read per distinct author for a batch of posts. */\nexport type WriteProofUtxoCache = Map<string, Promise<MempoolUtxo[] | null>>;\n\nexport interface EsploraOptions {\n /** Esplora API base; default https://mempool.space/api. */\n baseUrl?: string;\n fetch?: typeof fetch;\n /** Share one cache across the posts of a feed. */\n utxoCache?: WriteProofUtxoCache;\n}\n\nasync function addressUtxos(\n address: string,\n base: string,\n f: typeof fetch,\n cache?: WriteProofUtxoCache\n): Promise<MempoolUtxo[] | null> {\n const hit = cache?.get(address);\n if (hit) return hit;\n const p = (async () => {\n try {\n const res = await f(`${base}/address/${encodeURIComponent(address)}/utxo`);\n if (!res.ok) return null;\n return (await res.json()) as MempoolUtxo[];\n } catch {\n return null;\n }\n })();\n cache?.set(address, p);\n return p;\n}\n\n/**\n * Does a utxo-floor write proof match current chain state (§8.3.3), the\n * Bitcoin-load-bearing leg: the proof's outpoint is an unspent output of the\n * author's address with exactly the value, confirmation height and block hash\n * the proof states? 'no-answer' when the address's outputs could not be read.\n */\nexport async function writeProofOnChain(\n proof: WriteProof,\n authorAddress: string,\n opts: EsploraOptions = {}\n): Promise<boolean | 'no-answer'> {\n const base = (opts.baseUrl ?? 'https://mempool.space/api').replace(/\\/+$/, '');\n const f = opts.fetch ?? globalThis.fetch.bind(globalThis);\n const utxos = await addressUtxos(authorAddress, base, f, opts.utxoCache);\n if (!utxos) return 'no-answer';\n const sep = proof.outpoint.lastIndexOf(':');\n if (sep <= 0) return false;\n const txid = proof.outpoint.slice(0, sep);\n const vout = Number.parseInt(proof.outpoint.slice(sep + 1), 10);\n return utxos.some(\n (u) =>\n u.txid === txid &&\n u.vout === vout &&\n u.status?.confirmed === true &&\n u.value === proof.value_sats &&\n u.status?.block_height === proof.anchor_block_height &&\n u.status?.block_hash === proof.anchor_block_hash\n );\n}\n\n/** writeProofOnChain, fail-closed: an unanswered read is false, so the post does not render. */\nexport async function verifyWriteProofOnChain(\n proof: WriteProof,\n authorAddress: string,\n opts: EsploraOptions = {}\n): Promise<boolean> {\n return (await writeProofOnChain(proof, authorAddress, opts)) === true;\n}\n\n/**\n * A VerifyOnChain over Esplora, with one cache for the batch it is made for. An\n * unanswered read is false (a feed hides the post), or with `unanswered: 'throw'`\n * an UnansweredReadError, for a verifier that must say it could not tell.\n */\nexport function esploraWriteProofCheck(\n opts: EsploraOptions & { unanswered?: 'false' | 'throw' } = {}\n): VerifyOnChain {\n const utxoCache = opts.utxoCache ?? new Map();\n return async (proof, address) => {\n const r = await writeProofOnChain(proof, address, { ...opts, utxoCache });\n if (r === 'no-answer') {\n if (opts.unanswered === 'throw') throw new UnansweredReadError('the chain could not be read');\n return false;\n }\n return r;\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/io.ts"],"names":[],"mappings":"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","file":"io.mjs","sourcesContent":["// Readers over a public Esplora API (mempool.space by default) for the checks\n// chat-core leaves to its caller. Public chain data only.\n\nimport { normalizeAddress } from '@orangecheck/bip322';\n\nimport type { VerifyOnChain, WriteProof } from './channel';\nimport { UnansweredReadError } from './read';\n\n/** The coin a write proof names, as the explorer holds it. */\nexport interface OutpointRead {\n /** The output's scriptpubkey_address; null for a script with none. */\n address: string | null;\n value: number | null;\n confirmed: boolean;\n height: number | null;\n hash: string | null;\n /** Spent, a spend still in the mempool included. */\n spent: boolean;\n}\n\n/**\n * One read per outpoint for a batch of posts: two posts naming one coin read it\n * once. A read that got no answer is not kept, so the next asks again.\n */\nexport type WriteProofCache = Map<string, Promise<OutpointRead | 'missing' | 'unanswered'>>;\n\nexport interface EsploraOptions {\n /** Esplora API base; default https://mempool.space/api. */\n baseUrl?: string;\n fetch?: typeof fetch;\n /** Share one cache across the posts of a feed. */\n cache?: WriteProofCache;\n}\n\n/**\n * The one coin a write proof names (§8.3.3 (1)): GET /tx/<txid> for the output\n * at vout, then GET /tx/<txid>/outspend/<vout>. A 404 from either, or a vout the\n * transaction does not have, is 'missing'. A 429, a 5xx, a timeout or a body\n * that does not parse is 'unanswered'. Reading the coin rather than the\n * address's whole UTXO set holds for any wallet: Esplora refuses to list an\n * address past a few hundred UTXOs, and anyone can send an author dust.\n */\nexport async function readOutpoint(\n outpoint: string,\n opts: EsploraOptions = {}\n): Promise<OutpointRead | 'missing' | 'unanswered'> {\n const hit = opts.cache?.get(outpoint);\n if (hit) return hit;\n const base = (opts.baseUrl ?? 'https://mempool.space/api').replace(/\\/+$/, '');\n const f = opts.fetch ?? globalThis.fetch.bind(globalThis);\n const p = (async (): Promise<OutpointRead | 'missing' | 'unanswered'> => {\n const sep = outpoint.lastIndexOf(':');\n const txid = outpoint.slice(0, sep);\n const vout = Number(outpoint.slice(sep + 1));\n if (sep <= 0 || !/^[0-9a-f]{64}$/i.test(txid) || !Number.isInteger(vout) || vout < 0) {\n return 'missing';\n }\n try {\n const txRes = await f(`${base}/tx/${txid}`);\n if (txRes.status === 404) return 'missing';\n if (!txRes.ok) return 'unanswered';\n const tx = (await txRes.json()) as {\n vout?: { scriptpubkey_address?: string; value?: number }[];\n status?: { confirmed?: boolean; block_height?: number; block_hash?: string };\n };\n const out = tx.vout?.[vout];\n if (!out) return 'missing';\n const spendRes = await f(`${base}/tx/${txid}/outspend/${vout}`);\n if (spendRes.status === 404) return 'missing';\n if (!spendRes.ok) return 'unanswered';\n const spend = (await spendRes.json()) as { spent?: unknown };\n if (typeof spend.spent !== 'boolean') return 'unanswered';\n return {\n address: out.scriptpubkey_address ?? null,\n value: typeof out.value === 'number' ? out.value : null,\n confirmed: tx.status?.confirmed === true,\n height: tx.status?.block_height ?? null,\n hash: tx.status?.block_hash ?? null,\n spent: spend.spent,\n };\n } catch {\n return 'unanswered';\n }\n })();\n opts.cache?.set(outpoint, p);\n void p.then((r) => {\n if (r === 'unanswered' && opts.cache?.get(outpoint) === p) opts.cache.delete(outpoint);\n });\n return p;\n}\n\n/**\n * Does a utxo-floor write proof match current chain state (§8.3.3 (1)), the\n * Bitcoin-load-bearing leg: the proof's outpoint pays the author's address\n * (both in normal form, so a bech32 address in capitals is the same address)\n * exactly the value, confirmed at the height and block hash the proof states,\n * and is unspent (a spend still in the mempool counts as spent)? 'no-answer'\n * when the explorer did not answer; false when the coin is missing or differs.\n */\nexport async function writeProofOnChain(\n proof: WriteProof,\n authorAddress: string,\n opts: EsploraOptions = {}\n): Promise<boolean | 'no-answer'> {\n const coin = await readOutpoint(proof.outpoint, opts);\n if (coin === 'unanswered') return 'no-answer';\n if (coin === 'missing') return false;\n const owner = coin.address === null ? null : normalizeAddress(coin.address);\n return (\n owner !== null &&\n owner === normalizeAddress(authorAddress) &&\n coin.value === proof.value_sats &&\n coin.confirmed &&\n coin.height === proof.anchor_block_height &&\n coin.hash === proof.anchor_block_hash &&\n !coin.spent\n );\n}\n\n/** writeProofOnChain, fail-closed: an unanswered read is false, so the post does not render. */\nexport async function verifyWriteProofOnChain(\n proof: WriteProof,\n authorAddress: string,\n opts: EsploraOptions = {}\n): Promise<boolean> {\n return (await writeProofOnChain(proof, authorAddress, opts)) === true;\n}\n\n/**\n * A VerifyOnChain over Esplora, with one cache for the batch it is made for. A\n * read that gets no answer throws UnansweredReadError, so a verifier says it\n * could not tell rather than read the silence as \"no\" (verifyChannelPosts\n * counts the post unchecked). `unanswered: 'false'` makes it false instead, for\n * a caller that only needs fail-closed.\n */\nexport function esploraWriteProofCheck(\n opts: EsploraOptions & { unanswered?: 'throw' | 'false' } = {}\n): VerifyOnChain {\n const cache = opts.cache ?? new Map();\n return async (proof, address) => {\n const r = await writeProofOnChain(proof, address, { ...opts, cache });\n if (r === 'no-answer') {\n if (opts.unanswered === 'false') return false;\n throw new UnansweredReadError('the chain could not be read');\n }\n return r;\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangecheck/chat-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Verify OC Chat's public artifacts: channel descriptors and posts, directory listings and did:oc session devices. See https://github.com/orangecheck/oc-chat-protocol.",
5
5
  "keywords": [
6
6
  "bitcoin",
@@ -56,6 +56,7 @@
56
56
  "@noble/curves": "^1.9.7",
57
57
  "@noble/hashes": "^1.8.0",
58
58
  "@orangecheck/auth-core": "^2.27.0",
59
+ "@orangecheck/bip322": "^1.1.3",
59
60
  "@orangecheck/lock-core": "^1.4.0",
60
61
  "@orangecheck/lock-crypto": "^0.1.0",
61
62
  "@orangecheck/lock-device": "^0.8.0",
@@ -63,7 +64,6 @@
63
64
  "@orangecheck/stamp-ots": "^0.6.1"
64
65
  },
65
66
  "devDependencies": {
66
- "@orangecheck/bip322": "^1.1.3",
67
67
  "@types/node": "^22.10.2",
68
68
  "tsup": "^8.3.5",
69
69
  "typescript": "^5.7.2",
@@ -337,6 +337,25 @@ describe('readChannelVersions: "no newer version" only when every read met the f
337
337
  });
338
338
  });
339
339
 
340
+ describe('readChannelVersions: a page it cannot read past is a cut-off read (§8.3.1)', () => {
341
+ const ev = version(founderKey, genesis);
342
+ // 600 events under the channel's tag in one second, newer than the genesis: a
343
+ // page with \`until\` at that second brings back the same 500 every time.
344
+ const junk = Array.from({ length: 600 }, (_, i) => ({ ...ev, id: `junk-${i}`, created_at: ev.created_at + 86400, content: 'x' }));
345
+ const query = async (f: Record<string, unknown>) => {
346
+ const tagged = f['#t'] ? [...junk, ev].filter((e) => f.until === undefined || e.created_at <= (f.until as number)).slice(0, (f.limit as number) ?? Infinity) : [];
347
+ const byKey = Array.isArray(f.authors) && f.authors.includes(ev.pubkey) ? [ev] : [];
348
+ return { events: [...tagged, ...byKey], relayStatus: [{ eose: true }, { eose: true }, { eose: true }, { eose: true }] };
349
+ };
350
+ const keys = async () => ({ keys: [founderKey.nostrPk], answered: true, floor: true });
351
+
352
+ it('600 events in one second stall the open read: it is truncated, not complete', async () => {
353
+ const r = await readChannelVersions(genesis.channel_id, { query, everBoundKeys: keys }, FOUNDER);
354
+ expect(r.events.map((e) => e.id)).toContain(ev.id);
355
+ expect(r.truncated).toBe(true);
356
+ });
357
+ });
358
+
340
359
  describe('recordReads.liveness for an address binding with a relay that did not answer', () => {
341
360
  const ADDR = 'bc1qmr7qn2t0ahj56ztpdswt54kn2r863ukd8wadj8';
342
361
  const kp = generateDeviceKey();
@@ -34,6 +34,7 @@ import {
34
34
  type PostContent,
35
35
  } from './channel';
36
36
  import type { NostrEvent } from './event';
37
+ import { UnansweredReadError } from './read';
37
38
 
38
39
  const deriveChatIdentity = (sk: Uint8Array) => deriveNostrKey(sk);
39
40
 
@@ -1031,7 +1032,8 @@ describe('channels — a feed of posts (verifyChannelPosts, §8.3.2, §8.3.5)',
1031
1032
  const spoof = buildPostEvent({ deviceSk: ADMIN_DEVICE, content: { ...post('c'), author_inbox_pubkey: adminInbox }, createdAt: 150 });
1032
1033
  const elsewhere = buildPostEvent({ deviceSk: STRANGER_DEVICE, content: post('d', { channel_id: 'e'.repeat(64) }), createdAt: 120 });
1033
1034
  const got = await feed([a, b, spoof, elsewhere]);
1034
- expect(got.map((p) => p.post.body)).toEqual(['b', 'a']);
1035
+ expect(got.posts.map((p) => p.post.body)).toEqual(['b', 'a']);
1036
+ expect([got.unchecked, got.removalsChecked]).toEqual([0, true]);
1035
1037
  });
1036
1038
 
1037
1039
  it('a removal by a roster admin takes the post out; one by a stranger does not', async () => {
@@ -1043,20 +1045,132 @@ describe('channels — a feed of posts (verifyChannelPosts, §8.3.2, §8.3.5)',
1043
1045
  createdAt: 110,
1044
1046
  });
1045
1047
  const byStranger = buildPostEvent({ deviceSk: STRANGER_DEVICE, content: post('', { removes: pid }), createdAt: 110 });
1046
- expect(await feed([target, byStranger])).toHaveLength(1);
1047
- expect(await feed([target, byFounder])).toHaveLength(0);
1048
+ expect((await feed([target, byStranger])).posts).toHaveLength(1);
1049
+ expect((await feed([target, byFounder])).posts).toHaveLength(0);
1048
1050
  });
1049
1051
 
1050
1052
  it('two copies of one post count once, at the lower created_at', async () => {
1051
1053
  const first = buildPostEvent({ deviceSk: STRANGER_DEVICE, content: post('x'), createdAt: 300 });
1052
1054
  const again = buildPostEvent({ deviceSk: STRANGER_DEVICE, content: post('x'), createdAt: 100 });
1053
- const got = await feed([first, again]);
1054
- expect(got).toHaveLength(1);
1055
- expect(got[0]!.event.created_at).toBe(100);
1055
+ const { posts } = await feed([first, again]);
1056
+ expect(posts).toHaveLength(1);
1057
+ expect(posts[0]!.event.created_at).toBe(100);
1056
1058
  });
1057
1059
 
1058
1060
  it('a closed channel shows what was posted but takes no new post through the gate', async () => {
1059
1061
  const p = buildPostEvent({ deviceSk: STRANGER_DEVICE, content: post('late'), createdAt: 100 });
1060
- expect(await feed([p], { ...open, status: 'closed' })).toHaveLength(0);
1062
+ expect((await feed([p], { ...open, status: 'closed' })).posts).toHaveLength(0);
1063
+ });
1064
+ });
1065
+
1066
+ describe('channels — a gated feed when a read gets no answer (verifyChannelPosts, §8.3.3)', () => {
1067
+ const STRANGER_DEVICE = new Uint8Array(32).fill(13);
1068
+ const strangerInbox = deriveChatIdentity(STRANGER_DEVICE).nostrPk;
1069
+ const bound: IsBound = async (pk, addr) =>
1070
+ (await boundReal(pk, addr)) || (pk === strangerInbox && addr === STRANGER);
1071
+ const unread = async (): Promise<never> => {
1072
+ throw new UnansweredReadError('the chain could not be read');
1073
+ };
1074
+ const coin = (n: number) => `${'b'.repeat(63)}${n}:0`;
1075
+ /** A stranger's gated post naming coin n, with its control_sig. */
1076
+ const gated = (body: string, n: number, createdAt = 100) =>
1077
+ buildPostEvent({
1078
+ deviceSk: STRANGER_DEVICE,
1079
+ content: {
1080
+ ...POST,
1081
+ channel_id: vDescV1.channel_id,
1082
+ author_address: STRANGER,
1083
+ author_inbox_pubkey: strangerInbox,
1084
+ body,
1085
+ write_proof: { ...POST.write_proof!, outpoint: coin(n) },
1086
+ },
1087
+ createdAt,
1088
+ controlSig: 'sig',
1089
+ });
1090
+ const byFounder = (body: string, extra: Partial<PostContent> = {}) =>
1091
+ buildPostEvent({
1092
+ deviceSk: FOUNDER_DEVICE,
1093
+ content: {
1094
+ ...POST,
1095
+ channel_id: vDescV1.channel_id,
1096
+ author_address: FOUNDER,
1097
+ author_inbox_pubkey: founderInbox,
1098
+ body,
1099
+ write_proof: undefined,
1100
+ ...extra,
1101
+ },
1102
+ createdAt: 200,
1103
+ });
1104
+ const feed = (events: NostrEvent[], o: Partial<Parameters<typeof verifyChannelPosts>[2]> = {}) =>
1105
+ verifyChannelPosts(events, vDescV1, {
1106
+ tipHeight: 800200,
1107
+ isBound: bound,
1108
+ verifyBip322: yes,
1109
+ verifyOnChain: async (proof) => proof.outpoint !== coin(9),
1110
+ ...o,
1111
+ });
1112
+
1113
+ it('a coin read with no answer leaves that post unchecked, and the rest of the feed still verifies', async () => {
1114
+ const verifyOnChain = async (proof: { outpoint: string }) => {
1115
+ if (proof.outpoint === coin(1)) throw new UnansweredReadError('429');
1116
+ return true;
1117
+ };
1118
+ const got = await feed([gated('unread', 1), gated('read', 2)], { verifyOnChain });
1119
+ expect(got.posts.map((p) => p.post.body)).toEqual(['read']);
1120
+ expect(got.unchecked).toBe(1);
1121
+ });
1122
+
1123
+ it('a forged coin is dropped, not counted unchecked', async () => {
1124
+ const got = await feed([gated('forged', 9)]);
1125
+ expect([got.posts.length, got.unchecked]).toEqual([0, 0]);
1126
+ });
1127
+
1128
+ it('a binding read no relay answered leaves the post unchecked, not refused', async () => {
1129
+ const got = await feed([gated('a', 1)], { isBound: unread });
1130
+ expect([got.posts.length, got.unchecked]).toEqual([0, 1]);
1131
+ });
1132
+
1133
+ it("with no tip read, a stranger's gated post is unchecked and the founder's still shows", async () => {
1134
+ const got = await feed([gated('stranger', 1), byFounder('founder')], { tipHeight: 'unanswered' });
1135
+ expect(got.posts.map((p) => p.post.body)).toEqual(['founder']);
1136
+ expect(got.unchecked).toBe(1);
1137
+ });
1138
+
1139
+ it('with no tip read, a post the gate refuses on its face is still refused', async () => {
1140
+ const noSig = buildPostEvent({
1141
+ deviceSk: STRANGER_DEVICE,
1142
+ content: { ...POST, author_address: STRANGER, author_inbox_pubkey: strangerInbox, body: 'no sig' },
1143
+ createdAt: 100,
1144
+ });
1145
+ const got = await feed([noSig], { tipHeight: 'unanswered' });
1146
+ expect([got.posts.length, got.unchecked]).toEqual([0, 0]);
1147
+ });
1148
+
1149
+ it('a removal that could not be checked leaves removals unchecked', async () => {
1150
+ const target = gated('spam', 2);
1151
+ const removal = byFounder('', { removes: postId(JSON.parse(target.content) as PostContent) });
1152
+ const got = await feed([target, removal], { isBound: async (pk, addr) => (pk === founderInbox ? unread() : bound(pk, addr)) });
1153
+ expect(got.removalsChecked).toBe(false);
1154
+ expect(got.unchecked).toBe(1);
1155
+ });
1156
+
1157
+ it('a post shown from one copy is not counted unchecked for another', async () => {
1158
+ let first = true;
1159
+ const verifyOnChain = async () => {
1160
+ if (first) {
1161
+ first = false;
1162
+ throw new UnansweredReadError('429');
1163
+ }
1164
+ return true;
1165
+ };
1166
+ const got = await feed([gated('x', 1, 300), gated('x', 1, 100)], { verifyOnChain });
1167
+ expect([got.posts.length, got.unchecked]).toEqual([1, 0]);
1168
+ });
1169
+
1170
+ it('an error that is not an unanswered read still reaches the caller', async () => {
1171
+ const broken = async (): Promise<boolean> => {
1172
+ throw new TypeError('a bug, not a read');
1173
+ };
1174
+ await expect(feed([gated('a', 1)], { verifyOnChain: broken })).rejects.toBeInstanceOf(TypeError);
1061
1175
  });
1062
1176
  });