@orangecheck/chat-core 0.1.1 → 0.2.1

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);
@@ -1098,6 +1106,18 @@ export interface ChannelPost {
1098
1106
  event: NostrEvent;
1099
1107
  }
1100
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
+
1101
1121
  /**
1102
1122
  * Deterministic public-feed order (§8.3.2). A public multi-author channel has no
1103
1123
  * single hash-chain (independent broadcasters), so order is the event-level
@@ -1118,24 +1138,36 @@ export function orderChannelPosts(posts: ChannelPost[]): ChannelPost[] {
1118
1138
  * each one authored by a bound key and through the write gate, removal
1119
1139
  * tombstones applied (forward-effective, §8.3.5), copies of one post_id kept
1120
1140
  * once at their lowest created_at, in feed order. A post that fails MUST NOT
1121
- * 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.
1122
1144
  */
1123
1145
  export async function verifyChannelPosts(
1124
1146
  events: readonly NostrEvent[],
1125
1147
  descriptor: DescriptorContent,
1126
1148
  opts: Pick<VerifyPostOpts, 'tipHeight' | 'verifyBip322' | 'verifyOnChain'> & { isBound: IsBound }
1127
- ): Promise<ChannelPost[]> {
1149
+ ): Promise<ChannelPosts> {
1128
1150
  const removed = new Set<string>();
1129
1151
  const byPostId = new Map<string, ChannelPost>();
1152
+ const unchecked = new Set<string>();
1153
+ let removalsUnread = false;
1130
1154
  for (const ev of events) {
1131
1155
  const content = parsePost(ev);
1132
1156
  if (!content || content.channel_id !== descriptor.channel_id) continue;
1133
1157
  const controlSig = ev.tags.find((t) => t[0] === 'control_sig')?.[1];
1134
- const v = await verifyPost(content, descriptor, {
1135
- ...opts,
1136
- event: ev,
1137
- ...(controlSig ? { controlSig } : {}),
1138
- });
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
+ }
1139
1171
  if (!v.ok) continue;
1140
1172
  if (v.isTombstone && content.removes) {
1141
1173
  removed.add(content.removes);
@@ -1146,5 +1178,9 @@ export async function verifyChannelPosts(
1146
1178
  byPostId.set(v.postId, { post: content, postId: v.postId, event: ev });
1147
1179
  }
1148
1180
  }
1149
- 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
+ };
1150
1186
  }
@@ -283,6 +283,65 @@ describe('resolveDirectoryHandle', () => {
283
283
  });
284
284
  });
285
285
 
286
+ it('a full page of junk the reader drops is still a full page: the read goes on past it', async () => {
287
+ const { w, deps, anchorAt } = world();
288
+ const a = claim('alice', ALICE, TIP - 100);
289
+ anchorAt(a, ALICE);
290
+ w.bound.set(ALICE, [pk(1)]);
291
+ const newer = listing(2, 'alice', BOB, claim('alice', BOB, TIP - 50, 'bob'), {}, 1_790_000_900);
292
+ w.relays.set('wss://relay.ochk.io', [newer, listing(1, 'alice', ALICE, a, {}, 1_790_000_000)]);
293
+ const real = deps.page;
294
+ // The relay sends a full page: one listing that verifies, and 199 the reader
295
+ // drops for their signatures. Only the next page reaches alice's claim.
296
+ deps.page = async (relay, filter) => {
297
+ const got = await real(relay, filter);
298
+ if (relay !== 'wss://relay.ochk.io' || filter.until !== undefined) return got;
299
+ return { events: got.events.slice(0, 1), complete: true, sent: filter.limit };
300
+ };
301
+ expect(await resolveDirectoryHandle('alice', 'people', deps)).toMatchObject({
302
+ result: 'holds',
303
+ holder: ALICE,
304
+ });
305
+ });
306
+
307
+ it('a full page that is all junk has not been read completely, never "not listed"', async () => {
308
+ const { w, deps } = world();
309
+ w.relays.set('wss://relay.ochk.io', []);
310
+ const real = deps.page;
311
+ deps.page = async (relay, filter) =>
312
+ relay === 'wss://relay.ochk.io'
313
+ ? { events: [], complete: true, sent: filter.limit }
314
+ : real(relay, filter);
315
+ expect(await resolveDirectoryHandle('alice', 'people', deps)).toEqual({
316
+ result: 'E_DIR_UNRESOLVED',
317
+ });
318
+ });
319
+
320
+ it('an audit relay that pads a page with junk is read past it, so the earlier claim it holds counts', async () => {
321
+ const { w, deps, anchorAt, publish } = world();
322
+ const a = claim('alice', ALICE, TIP - 100);
323
+ const b = claim('alice', BOB, TIP - 50, 'bob');
324
+ anchorAt(a, ALICE);
325
+ anchorAt(b, BOB);
326
+ w.bound.set(ALICE, [pk(1)]);
327
+ w.bound.set(BOB, [pk(2)]);
328
+ publish(listing(2, 'alice', BOB, b));
329
+ w.relays.set('wss://nos.lol', [
330
+ listing(2, 'alice', BOB, b, {}, 1_790_000_900),
331
+ listing(1, 'alice', ALICE, a, {}, 1_790_000_000),
332
+ ]);
333
+ const real = deps.page;
334
+ deps.page = async (relay, filter) => {
335
+ const got = await real(relay, filter);
336
+ if (relay !== 'wss://nos.lol' || filter.until !== undefined) return got;
337
+ return { events: got.events.slice(0, 1), complete: true, sent: filter.limit };
338
+ };
339
+ expect(await resolveDirectoryHandle('alice', 'people', deps)).toMatchObject({
340
+ result: 'holds',
341
+ holder: ALICE,
342
+ });
343
+ });
344
+
286
345
  it('a directory relay that stalls on a full page has not been read completely', async () => {
287
346
  const { w, deps } = world();
288
347
  const same = Array.from({ length: 200 }, (_, i) =>
package/src/directory.ts CHANGED
@@ -49,11 +49,21 @@ const AUDIT_PAGES = 2;
49
49
  /** Distinct header reads one resolution may make; past this a header is unread. */
50
50
  const HEADER_BUDGET = 128;
51
51
 
52
- /** One page from one relay: its events, and whether the relay finished answering (EOSE). */
52
+ /**
53
+ * One page from one relay: its events, whether the relay finished answering
54
+ * (EOSE), and `sent`, how many events the relay sent, the ones the reader dropped
55
+ * (a bad signature) included. A page is full by what the relay sent, not by what
56
+ * was kept: a relay that pads a page with junk has more behind it. Without
57
+ * `sent`, the events kept are the count.
58
+ */
53
59
  export type PageRead = (
54
60
  relay: string,
55
61
  filter: { kinds: number[]; '#d': string[]; limit: number; until?: number }
56
- ) => Promise<{ events: NostrEvent[]; complete: boolean }>;
62
+ ) => Promise<{ events: NostrEvent[]; complete: boolean; sent?: number }>;
63
+
64
+ /** How many events a page's relay sent: `sent`, or the events kept when it is not given. */
65
+ const sentOn = (got: { events: NostrEvent[]; sent?: number }) =>
66
+ Math.max(got.sent ?? 0, got.events.length);
57
67
 
58
68
  export interface WalkDeps {
59
69
  page: PageRead;
@@ -148,7 +158,7 @@ async function readAll(
148
158
  until = until === undefined ? ev.created_at : Math.min(until, ev.created_at);
149
159
  }
150
160
  // A short page is the end; a full page that adds nothing is a stalled read.
151
- if (got.events.length < PAGE) return [...seen.values()];
161
+ if (sentOn(got) < PAGE) return [...seen.values()];
152
162
  if (added === 0) return null;
153
163
  }
154
164
  return null;
@@ -164,7 +174,7 @@ async function readSome(
164
174
  const seen = new Map<string, NostrEvent>();
165
175
  let until: number | undefined;
166
176
  for (let i = 0; i < pages; i++) {
167
- let got: { events: NostrEvent[]; complete: boolean };
177
+ let got: Awaited<ReturnType<PageRead>>;
168
178
  try {
169
179
  got = await page(relay, {
170
180
  kinds: [30114],
@@ -179,7 +189,7 @@ async function readSome(
179
189
  seen.set(ev.id, ev);
180
190
  until = until === undefined ? ev.created_at : Math.min(until, ev.created_at);
181
191
  }
182
- if (!got.complete || got.events.length < PAGE) break;
192
+ if (!got.complete || sentOn(got) < PAGE) break;
183
193
  }
184
194
  return [...seen.values()];
185
195
  }
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
  };