@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/src/channel.ts CHANGED
@@ -69,7 +69,8 @@ export type ChannelError =
69
69
  export type IsBound = (pubkey: string, principal: string) => Promise<boolean>;
70
70
 
71
71
  /** Does this utxo-floor write proof match current chain state: an unspent output
72
- * of `address` with exactly the proof's value, height and block hash (§8.3.3)? */
72
+ * of `address` with exactly the proof's value, height and block hash (§8.3.3)?
73
+ * A reader that could not tell throws UnansweredReadError, not false. */
73
74
  export type VerifyOnChain = (proof: WriteProof, address: string) => Promise<boolean>;
74
75
 
75
76
  export type WritePolicy = 'utxo-floor' | 'allowlist' | 'founder' | 'open';
@@ -425,8 +426,10 @@ export async function verifyDescriptor(
425
426
  }
426
427
 
427
428
  export interface VerifyPostOpts {
428
- /** Reader's current tip height (one read per render batch). */
429
- tipHeight: number;
429
+ /** Reader's current tip height (one read per render batch), or 'unanswered'
430
+ * when it could not be read: a utxo-floor post that needs it then throws
431
+ * UnansweredReadError rather than being refused. */
432
+ tipHeight: number | 'unanswered';
430
433
  /** BIP-322 by author_address over post_id — REQUIRED for utxo-floor. */
431
434
  controlSig?: string;
432
435
  /** The full writer-address list for an allowlist channel (membership proof). */
@@ -441,7 +444,8 @@ export interface VerifyPostOpts {
441
444
  /** A non-empty recipients[] on a chat-channel envelope is E_CHANNEL_RECIPIENTS. */
442
445
  recipients?: unknown[];
443
446
  /** utxo-floor: binds the write_proof to real chain state (io.ts has one
444
- * over an esplora API). Fail-closed: false drops the post. */
447
+ * over an esplora API). Fail-closed: false drops the post; a read that got
448
+ * no answer throws UnansweredReadError. */
445
449
  verifyOnChain: VerifyOnChain;
446
450
  }
447
451
 
@@ -556,6 +560,10 @@ async function checkWritePermission(
556
560
  if (!content.write_proof) return 'E_CHAN_FLOOR';
557
561
  if (!opts.controlSig) return 'E_CHAN_FLOOR';
558
562
  if (!isBitcoinAddress(content.author_address)) return 'E_CHAN_FLOOR';
563
+ // With no tip read, the age cannot be told: not a refusal (§8.3.3).
564
+ if (opts.tipHeight === 'unanswered') {
565
+ throw new UnansweredReadError('the chain tip could not be read');
566
+ }
559
567
  // Cheap offline gates first (no network): the floor arithmetic + the
560
568
  // control_sig (author controls the address, over post_id).
561
569
  const v = evaluateWriteProof(content.write_proof, w, opts.tipHeight);
@@ -979,9 +987,13 @@ export async function readChannelVersions(
979
987
  });
980
988
  const fresh = add(res);
981
989
  for (const e of res.events ?? []) until = Math.min(until ?? Infinity, e.created_at);
982
- // A full page that brought something new may have more behind it.
983
- if ((res.events ?? []).length < VERSION_PAGE_LIMIT || fresh === 0) break;
984
- if (page === VERSION_MAX_PAGES - 1) truncated = true;
990
+ // A full page may have more behind it. One that brought nothing new is
991
+ // stuck on a second it cannot page past: the read stops there, cut off.
992
+ if ((res.events ?? []).length < VERSION_PAGE_LIMIT) break;
993
+ if (fresh === 0 || page === VERSION_MAX_PAGES - 1) {
994
+ truncated = true;
995
+ break;
996
+ }
985
997
  }
986
998
  add(await legacy);
987
999
 
@@ -1094,6 +1106,18 @@ export interface ChannelPost {
1094
1106
  event: NostrEvent;
1095
1107
  }
1096
1108
 
1109
+ export interface ChannelPosts {
1110
+ /** The posts that verify, removals applied, in feed order. */
1111
+ posts: ChannelPost[];
1112
+ /** Posts that could not be checked (a binding, coin or tip read with no
1113
+ * answer): never shown, and counted so a reader can say so and try again
1114
+ * (§8.3.3). A post shown from another copy is not counted. */
1115
+ unchecked: number;
1116
+ /** False when a removal could not be checked, so a post it names may be
1117
+ * on show. */
1118
+ removalsChecked: boolean;
1119
+ }
1120
+
1097
1121
  /**
1098
1122
  * Deterministic public-feed order (§8.3.2). A public multi-author channel has no
1099
1123
  * single hash-chain (independent broadcasters), so order is the event-level
@@ -1114,24 +1138,36 @@ export function orderChannelPosts(posts: ChannelPost[]): ChannelPost[] {
1114
1138
  * each one authored by a bound key and through the write gate, removal
1115
1139
  * tombstones applied (forward-effective, §8.3.5), copies of one post_id kept
1116
1140
  * once at their lowest created_at, in feed order. A post that fails MUST NOT
1117
- * render, so it is left out.
1141
+ * render, so it is left out. A post whose binding, coin or tip read got no
1142
+ * answer (UnansweredReadError) is not shown either, and is counted unchecked
1143
+ * rather than refused; the rest of the feed still verifies.
1118
1144
  */
1119
1145
  export async function verifyChannelPosts(
1120
1146
  events: readonly NostrEvent[],
1121
1147
  descriptor: DescriptorContent,
1122
1148
  opts: Pick<VerifyPostOpts, 'tipHeight' | 'verifyBip322' | 'verifyOnChain'> & { isBound: IsBound }
1123
- ): Promise<ChannelPost[]> {
1149
+ ): Promise<ChannelPosts> {
1124
1150
  const removed = new Set<string>();
1125
1151
  const byPostId = new Map<string, ChannelPost>();
1152
+ const unchecked = new Set<string>();
1153
+ let removalsUnread = false;
1126
1154
  for (const ev of events) {
1127
1155
  const content = parsePost(ev);
1128
1156
  if (!content || content.channel_id !== descriptor.channel_id) continue;
1129
1157
  const controlSig = ev.tags.find((t) => t[0] === 'control_sig')?.[1];
1130
- const v = await verifyPost(content, descriptor, {
1131
- ...opts,
1132
- event: ev,
1133
- ...(controlSig ? { controlSig } : {}),
1134
- });
1158
+ let v: PostVerify;
1159
+ try {
1160
+ v = await verifyPost(content, descriptor, {
1161
+ ...opts,
1162
+ event: ev,
1163
+ ...(controlSig ? { controlSig } : {}),
1164
+ });
1165
+ } catch (e) {
1166
+ if (!(e instanceof UnansweredReadError)) throw e;
1167
+ unchecked.add(postId(content));
1168
+ if (typeof content.removes === 'string') removalsUnread = true;
1169
+ continue;
1170
+ }
1135
1171
  if (!v.ok) continue;
1136
1172
  if (v.isTombstone && content.removes) {
1137
1173
  removed.add(content.removes);
@@ -1142,5 +1178,9 @@ export async function verifyChannelPosts(
1142
1178
  byPostId.set(v.postId, { post: content, postId: v.postId, event: ev });
1143
1179
  }
1144
1180
  }
1145
- return orderChannelPosts([...byPostId.values()].filter((a) => !removed.has(a.postId)));
1181
+ return {
1182
+ posts: orderChannelPosts([...byPostId.values()].filter((a) => !removed.has(a.postId))),
1183
+ unchecked: [...unchecked].filter((id) => !byPostId.has(id)).length,
1184
+ removalsChecked: !removalsUnread,
1185
+ };
1146
1186
  }
package/src/io.test.ts CHANGED
@@ -1,9 +1,11 @@
1
- // The on-chain leg of the utxo-floor gate (SPEC §8.3.3), over a stubbed Esplora.
1
+ // The on-chain leg of the utxo-floor gate (SPEC §8.3.3 (1)), over a stubbed
2
+ // Esplora: the coin a proof names is read on its own, its transaction and then
3
+ // its outspend, so a wallet of any size can be checked.
2
4
 
3
5
  import { describe, expect, it } from 'vitest';
4
6
 
5
7
  import type { WriteProof } from './channel';
6
- import { esploraWriteProofCheck, verifyWriteProofOnChain } from './io';
8
+ import { esploraWriteProofCheck, readOutpoint, verifyWriteProofOnChain, writeProofOnChain } from './io';
7
9
  import { UnansweredReadError } from './read';
8
10
 
9
11
  const AUTHOR = 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4';
@@ -13,76 +15,120 @@ const PROOF: WriteProof = {
13
15
  anchor_block_height: 800000,
14
16
  anchor_block_hash: '00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054',
15
17
  };
16
- const [TXID, VOUT] = PROOF.outpoint.split(':') as [string, string];
17
- /** The author's UTXO set as Esplora returns it. */
18
- const coin = {
19
- txid: TXID,
20
- vout: Number(VOUT),
21
- value: PROOF.value_sats,
22
- status: { confirmed: true, block_height: PROOF.anchor_block_height, block_hash: PROOF.anchor_block_hash },
23
- };
24
- const serving = (utxos: unknown, seen?: string[]) =>
25
- (async (url: string) => {
26
- seen?.push(url);
27
- return { ok: true, json: async () => utxos } as unknown as Response;
28
- }) as unknown as typeof fetch;
18
+ const [TXID] = PROOF.outpoint.split(':') as [string, string];
29
19
 
30
- describe('verifyWriteProofOnChain', () => {
31
- it('accepts a proof matching an unspent output of the address, exact in value, height and hash', async () => {
32
- expect(await verifyWriteProofOnChain(PROOF, AUTHOR, { fetch: serving([coin]) })).toBe(true);
33
- });
20
+ /** The coin as Esplora holds it, and how each of its two reads answers. */
21
+ interface Coin {
22
+ address?: string;
23
+ value?: number;
24
+ confirmed?: boolean;
25
+ height?: number;
26
+ hash?: string;
27
+ spent?: boolean;
28
+ /** No output at vout 0. */
29
+ noVout?: boolean;
30
+ txStatus?: number;
31
+ spendStatus?: number;
32
+ }
34
33
 
35
- it('refuses a value the coin does not carry', async () => {
36
- const forged = { ...PROOF, value_sats: PROOF.value_sats + 1 };
37
- expect(await verifyWriteProofOnChain(forged, AUTHOR, { fetch: serving([coin]) })).toBe(false);
34
+ function explorer(coin: Coin = {}) {
35
+ const calls: string[] = [];
36
+ const f = async (url: string | URL) => {
37
+ const u = String(url);
38
+ calls.push(u.replace(/^.*\/api/, ''));
39
+ // A large wallet: Esplora will not list the address's set at all.
40
+ if (u.includes('/address/')) return new Response('Too many unspent transaction outputs (>500).', { status: 400 });
41
+ if (u.endsWith('/outspend/0')) {
42
+ if (coin.spendStatus) return new Response('', { status: coin.spendStatus });
43
+ return Response.json({ spent: coin.spent ?? false });
44
+ }
45
+ if (coin.txStatus) return new Response('', { status: coin.txStatus });
46
+ return Response.json({
47
+ vout: coin.noVout ? [] : [{ scriptpubkey_address: coin.address ?? AUTHOR, value: coin.value ?? PROOF.value_sats }],
48
+ status: {
49
+ confirmed: coin.confirmed ?? true,
50
+ block_height: coin.height ?? PROOF.anchor_block_height,
51
+ block_hash: coin.hash ?? PROOF.anchor_block_hash,
52
+ },
53
+ });
54
+ };
55
+ return { f: f as unknown as typeof fetch, calls };
56
+ }
57
+
58
+ describe('writeProofOnChain reads the one coin a proof names', () => {
59
+ it('verifies it for a wallet too big for the explorer to list, with the transaction and its outspend only', async () => {
60
+ const { f, calls } = explorer();
61
+ expect(await writeProofOnChain(PROOF, AUTHOR, { fetch: f })).toBe(true);
62
+ expect(calls).toEqual([`/tx/${TXID}`, `/tx/${TXID}/outspend/0`]);
38
63
  });
39
64
 
40
- it('refuses an outpoint not in the address UTXO set (not owned, or spent)', async () => {
41
- expect(await verifyWriteProofOnChain(PROOF, AUTHOR, { fetch: serving([]) })).toBe(false);
65
+ it('compares the address in normal form: a bech32 author in capitals is the same address', async () => {
66
+ expect(await writeProofOnChain(PROOF, AUTHOR.toUpperCase(), { fetch: explorer().f })).toBe(true);
67
+ expect(await writeProofOnChain(PROOF, AUTHOR, { fetch: explorer({ address: AUTHOR.toUpperCase() }).f })).toBe(true);
42
68
  });
43
69
 
44
- it('refuses another anchor height or block hash', async () => {
45
- const f = serving([coin]);
46
- expect(await verifyWriteProofOnChain({ ...PROOF, anchor_block_height: 799995 }, AUTHOR, { fetch: f })).toBe(false);
47
- expect(await verifyWriteProofOnChain({ ...PROOF, anchor_block_hash: '0'.repeat(64) }, AUTHOR, { fetch: f })).toBe(false);
70
+ it.each<[string, Coin, Partial<WriteProof>?]>([
71
+ ['a spent coin (a mempool spend included)', { spent: true }],
72
+ ['another address', { address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' }],
73
+ ['another value', { value: PROOF.value_sats + 1 }],
74
+ ['an unconfirmed coin', { confirmed: false }],
75
+ ['another anchor height', {}, { anchor_block_height: 799995 }],
76
+ ['another block hash', {}, { anchor_block_hash: '0'.repeat(64) }],
77
+ ['no such transaction (404)', { txStatus: 404 }],
78
+ ['no such output to spend (404 on the outspend)', { spendStatus: 404 }],
79
+ ['a vout the transaction does not have', { noVout: true }],
80
+ ])('refuses %s', async (_name, coin, proof = {}) => {
81
+ expect(await writeProofOnChain({ ...PROOF, ...proof }, AUTHOR, { fetch: explorer(coin).f })).toBe(false);
48
82
  });
49
83
 
50
- it('refuses an unconfirmed coin', async () => {
51
- const pending = { ...coin, status: { ...coin.status, confirmed: false } };
52
- expect(await verifyWriteProofOnChain(PROOF, AUTHOR, { fetch: serving([pending]) })).toBe(false);
84
+ it.each<[string, Coin]>([
85
+ ['a 429 on the transaction', { txStatus: 429 }],
86
+ ['a 503 on the outspend', { spendStatus: 503 }],
87
+ ])('answers no-answer for %s, and does not keep it', async (_name, coin) => {
88
+ const cache = new Map();
89
+ expect(await writeProofOnChain(PROOF, AUTHOR, { fetch: explorer(coin).f, cache })).toBe('no-answer');
90
+ await Promise.resolve();
91
+ expect(cache.size).toBe(0);
92
+ // The next read gets an answer: nothing unanswered was kept.
93
+ expect(await writeProofOnChain(PROOF, AUTHOR, { fetch: explorer().f, cache })).toBe(true);
53
94
  });
54
95
 
55
- it('is false when the explorer does not answer', async () => {
56
- const down = (async () => ({ ok: false }) as unknown as Response) as unknown as typeof fetch;
96
+ it('a thrown fetch or a body that does not parse is unanswered', async () => {
57
97
  const throws = (async () => {
58
98
  throw new Error('offline');
59
99
  }) as unknown as typeof fetch;
60
- expect(await verifyWriteProofOnChain(PROOF, AUTHOR, { fetch: down })).toBe(false);
61
- expect(await verifyWriteProofOnChain(PROOF, AUTHOR, { fetch: throws })).toBe(false);
100
+ const garbled = (async () => new Response('<html>busy</html>')) as unknown as typeof fetch;
101
+ expect(await readOutpoint(PROOF.outpoint, { fetch: throws })).toBe('unanswered');
102
+ expect(await readOutpoint(PROOF.outpoint, { fetch: garbled })).toBe('unanswered');
62
103
  });
63
104
 
64
- it('reads each address once per batch', async () => {
65
- const seen: string[] = [];
66
- const check = esploraWriteProofCheck({ baseUrl: 'https://esplora.test/api/', fetch: serving([coin], seen) });
67
- expect(await check(PROOF, AUTHOR)).toBe(true);
68
- expect(await check({ ...PROOF, value_sats: 1 }, AUTHOR)).toBe(false);
69
- expect(seen).toEqual([`https://esplora.test/api/address/${AUTHOR}/utxo`]);
105
+ it('verifyWriteProofOnChain is fail-closed: false when the explorer does not answer', async () => {
106
+ expect(await verifyWriteProofOnChain(PROOF, AUTHOR, { fetch: explorer({ txStatus: 503 }).f })).toBe(false);
107
+ expect(await verifyWriteProofOnChain(PROOF, AUTHOR, { fetch: explorer().f })).toBe(true);
70
108
  });
71
109
  });
72
110
 
73
- describe('esploraWriteProofCheck on a read that gets no answer', () => {
74
- const down = (async () => {
75
- throw new Error('offline');
76
- }) as unknown as typeof fetch;
77
-
78
- it('is false by default, so a feed hides the post', async () => {
79
- expect(await esploraWriteProofCheck({ fetch: down })(PROOF, AUTHOR)).toBe(false);
111
+ describe('esploraWriteProofCheck', () => {
112
+ it('reads each coin once per batch, at the base it is given', async () => {
113
+ const seen: string[] = [];
114
+ const f = (async (url: string) => {
115
+ seen.push(url);
116
+ return explorer().f(url);
117
+ }) as unknown as typeof fetch;
118
+ const check = esploraWriteProofCheck({ baseUrl: 'https://esplora.test/api/', fetch: f });
119
+ expect(await check(PROOF, AUTHOR)).toBe(true);
120
+ expect(await check({ ...PROOF, value_sats: 1 }, AUTHOR)).toBe(false);
121
+ expect(seen).toEqual([`https://esplora.test/api/tx/${TXID}`, `https://esplora.test/api/tx/${TXID}/outspend/0`]);
80
122
  });
81
123
 
82
- it('throws UnansweredReadError when asked to, and still answers false for a coin that does not match', async () => {
83
- await expect(esploraWriteProofCheck({ fetch: down, unanswered: 'throw' })(PROOF, AUTHOR)).rejects.toBeInstanceOf(
124
+ it('throws UnansweredReadError on a read that gets no answer, so a verifier never reads silence as "no"', async () => {
125
+ await expect(esploraWriteProofCheck({ fetch: explorer({ txStatus: 429 }).f })(PROOF, AUTHOR)).rejects.toBeInstanceOf(
84
126
  UnansweredReadError
85
127
  );
86
- expect(await esploraWriteProofCheck({ fetch: serving([]), unanswered: 'throw' })(PROOF, AUTHOR)).toBe(false);
128
+ expect(await esploraWriteProofCheck({ fetch: explorer({ spent: true }).f })(PROOF, AUTHOR)).toBe(false);
129
+ });
130
+
131
+ it("is false instead with unanswered: 'false', for a caller that only needs fail-closed", async () => {
132
+ expect(await esploraWriteProofCheck({ fetch: explorer({ txStatus: 429 }).f, unanswered: 'false' })(PROOF, AUTHOR)).toBe(false);
87
133
  });
88
134
  });
package/src/io.ts CHANGED
@@ -1,76 +1,119 @@
1
1
  // Readers over a public Esplora API (mempool.space by default) for the checks
2
2
  // chat-core leaves to its caller. Public chain data only.
3
3
 
4
+ import { normalizeAddress } from '@orangecheck/bip322';
5
+
4
6
  import type { VerifyOnChain, WriteProof } from './channel';
5
7
  import { UnansweredReadError } from './read';
6
8
 
7
- /** One output from Esplora's /address/<addr>/utxo. */
8
- export interface MempoolUtxo {
9
- txid: string;
10
- vout: number;
11
- value?: number;
12
- status?: { confirmed?: boolean; block_height?: number; block_hash?: string };
9
+ /** The coin a write proof names, as the explorer holds it. */
10
+ export interface OutpointRead {
11
+ /** The output's scriptpubkey_address; null for a script with none. */
12
+ address: string | null;
13
+ value: number | null;
14
+ confirmed: boolean;
15
+ height: number | null;
16
+ hash: string | null;
17
+ /** Spent, a spend still in the mempool included. */
18
+ spent: boolean;
13
19
  }
14
20
 
15
- /** One address→UTXO-set read per distinct author for a batch of posts. */
16
- export type WriteProofUtxoCache = Map<string, Promise<MempoolUtxo[] | null>>;
21
+ /**
22
+ * One read per outpoint for a batch of posts: two posts naming one coin read it
23
+ * once. A read that got no answer is not kept, so the next asks again.
24
+ */
25
+ export type WriteProofCache = Map<string, Promise<OutpointRead | 'missing' | 'unanswered'>>;
17
26
 
18
27
  export interface EsploraOptions {
19
28
  /** Esplora API base; default https://mempool.space/api. */
20
29
  baseUrl?: string;
21
30
  fetch?: typeof fetch;
22
31
  /** Share one cache across the posts of a feed. */
23
- utxoCache?: WriteProofUtxoCache;
32
+ cache?: WriteProofCache;
24
33
  }
25
34
 
26
- async function addressUtxos(
27
- address: string,
28
- base: string,
29
- f: typeof fetch,
30
- cache?: WriteProofUtxoCache
31
- ): Promise<MempoolUtxo[] | null> {
32
- const hit = cache?.get(address);
35
+ /**
36
+ * The one coin a write proof names (§8.3.3 (1)): GET /tx/<txid> for the output
37
+ * at vout, then GET /tx/<txid>/outspend/<vout>. A 404 from either, or a vout the
38
+ * transaction does not have, is 'missing'. A 429, a 5xx, a timeout or a body
39
+ * that does not parse is 'unanswered'. Reading the coin rather than the
40
+ * address's whole UTXO set holds for any wallet: Esplora refuses to list an
41
+ * address past a few hundred UTXOs, and anyone can send an author dust.
42
+ */
43
+ export async function readOutpoint(
44
+ outpoint: string,
45
+ opts: EsploraOptions = {}
46
+ ): Promise<OutpointRead | 'missing' | 'unanswered'> {
47
+ const hit = opts.cache?.get(outpoint);
33
48
  if (hit) return hit;
34
- const p = (async () => {
49
+ const base = (opts.baseUrl ?? 'https://mempool.space/api').replace(/\/+$/, '');
50
+ const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
51
+ const p = (async (): Promise<OutpointRead | 'missing' | 'unanswered'> => {
52
+ const sep = outpoint.lastIndexOf(':');
53
+ const txid = outpoint.slice(0, sep);
54
+ const vout = Number(outpoint.slice(sep + 1));
55
+ if (sep <= 0 || !/^[0-9a-f]{64}$/i.test(txid) || !Number.isInteger(vout) || vout < 0) {
56
+ return 'missing';
57
+ }
35
58
  try {
36
- const res = await f(`${base}/address/${encodeURIComponent(address)}/utxo`);
37
- if (!res.ok) return null;
38
- return (await res.json()) as MempoolUtxo[];
59
+ const txRes = await f(`${base}/tx/${txid}`);
60
+ if (txRes.status === 404) return 'missing';
61
+ if (!txRes.ok) return 'unanswered';
62
+ const tx = (await txRes.json()) as {
63
+ vout?: { scriptpubkey_address?: string; value?: number }[];
64
+ status?: { confirmed?: boolean; block_height?: number; block_hash?: string };
65
+ };
66
+ const out = tx.vout?.[vout];
67
+ if (!out) return 'missing';
68
+ const spendRes = await f(`${base}/tx/${txid}/outspend/${vout}`);
69
+ if (spendRes.status === 404) return 'missing';
70
+ if (!spendRes.ok) return 'unanswered';
71
+ const spend = (await spendRes.json()) as { spent?: unknown };
72
+ if (typeof spend.spent !== 'boolean') return 'unanswered';
73
+ return {
74
+ address: out.scriptpubkey_address ?? null,
75
+ value: typeof out.value === 'number' ? out.value : null,
76
+ confirmed: tx.status?.confirmed === true,
77
+ height: tx.status?.block_height ?? null,
78
+ hash: tx.status?.block_hash ?? null,
79
+ spent: spend.spent,
80
+ };
39
81
  } catch {
40
- return null;
82
+ return 'unanswered';
41
83
  }
42
84
  })();
43
- cache?.set(address, p);
85
+ opts.cache?.set(outpoint, p);
86
+ void p.then((r) => {
87
+ if (r === 'unanswered' && opts.cache?.get(outpoint) === p) opts.cache.delete(outpoint);
88
+ });
44
89
  return p;
45
90
  }
46
91
 
47
92
  /**
48
- * Does a utxo-floor write proof match current chain state (§8.3.3), the
49
- * Bitcoin-load-bearing leg: the proof's outpoint is an unspent output of the
50
- * author's address with exactly the value, confirmation height and block hash
51
- * the proof states? 'no-answer' when the address's outputs could not be read.
93
+ * Does a utxo-floor write proof match current chain state (§8.3.3 (1)), the
94
+ * Bitcoin-load-bearing leg: the proof's outpoint pays the author's address
95
+ * (both in normal form, so a bech32 address in capitals is the same address)
96
+ * exactly the value, confirmed at the height and block hash the proof states,
97
+ * and is unspent (a spend still in the mempool counts as spent)? 'no-answer'
98
+ * when the explorer did not answer; false when the coin is missing or differs.
52
99
  */
53
100
  export async function writeProofOnChain(
54
101
  proof: WriteProof,
55
102
  authorAddress: string,
56
103
  opts: EsploraOptions = {}
57
104
  ): Promise<boolean | 'no-answer'> {
58
- const base = (opts.baseUrl ?? 'https://mempool.space/api').replace(/\/+$/, '');
59
- const f = opts.fetch ?? globalThis.fetch.bind(globalThis);
60
- const utxos = await addressUtxos(authorAddress, base, f, opts.utxoCache);
61
- if (!utxos) return 'no-answer';
62
- const sep = proof.outpoint.lastIndexOf(':');
63
- if (sep <= 0) return false;
64
- const txid = proof.outpoint.slice(0, sep);
65
- const vout = Number.parseInt(proof.outpoint.slice(sep + 1), 10);
66
- return utxos.some(
67
- (u) =>
68
- u.txid === txid &&
69
- u.vout === vout &&
70
- u.status?.confirmed === true &&
71
- u.value === proof.value_sats &&
72
- u.status?.block_height === proof.anchor_block_height &&
73
- u.status?.block_hash === proof.anchor_block_hash
105
+ const coin = await readOutpoint(proof.outpoint, opts);
106
+ if (coin === 'unanswered') return 'no-answer';
107
+ if (coin === 'missing') return false;
108
+ const owner = coin.address === null ? null : normalizeAddress(coin.address);
109
+ return (
110
+ owner !== null &&
111
+ owner === normalizeAddress(authorAddress) &&
112
+ coin.value === proof.value_sats &&
113
+ coin.confirmed &&
114
+ coin.height === proof.anchor_block_height &&
115
+ coin.hash === proof.anchor_block_hash &&
116
+ !coin.spent
74
117
  );
75
118
  }
76
119
 
@@ -84,19 +127,21 @@ export async function verifyWriteProofOnChain(
84
127
  }
85
128
 
86
129
  /**
87
- * A VerifyOnChain over Esplora, with one cache for the batch it is made for. An
88
- * unanswered read is false (a feed hides the post), or with `unanswered: 'throw'`
89
- * an UnansweredReadError, for a verifier that must say it could not tell.
130
+ * A VerifyOnChain over Esplora, with one cache for the batch it is made for. A
131
+ * read that gets no answer throws UnansweredReadError, so a verifier says it
132
+ * could not tell rather than read the silence as "no" (verifyChannelPosts
133
+ * counts the post unchecked). `unanswered: 'false'` makes it false instead, for
134
+ * a caller that only needs fail-closed.
90
135
  */
91
136
  export function esploraWriteProofCheck(
92
- opts: EsploraOptions & { unanswered?: 'false' | 'throw' } = {}
137
+ opts: EsploraOptions & { unanswered?: 'throw' | 'false' } = {}
93
138
  ): VerifyOnChain {
94
- const utxoCache = opts.utxoCache ?? new Map();
139
+ const cache = opts.cache ?? new Map();
95
140
  return async (proof, address) => {
96
- const r = await writeProofOnChain(proof, address, { ...opts, utxoCache });
141
+ const r = await writeProofOnChain(proof, address, { ...opts, cache });
97
142
  if (r === 'no-answer') {
98
- if (opts.unanswered === 'throw') throw new UnansweredReadError('the chain could not be read');
99
- return false;
143
+ if (opts.unanswered === 'false') return false;
144
+ throw new UnansweredReadError('the chain could not be read');
100
145
  }
101
146
  return r;
102
147
  };