@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/{channel-D8aOo4eI.d.mts → channel-ChK3AAvd.d.mts} +8 -3
- package/dist/{channel-D8aOo4eI.d.ts → channel-ChK3AAvd.d.ts} +8 -3
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +28 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +28 -8
- package/dist/index.mjs.map +1 -1
- package/dist/io.d.mts +13 -14
- package/dist/io.d.ts +13 -14
- package/dist/io.js +45 -23
- package/dist/io.js.map +1 -1
- package/dist/io.mjs +45 -24
- package/dist/io.mjs.map +1 -1
- package/package.json +2 -2
- package/src/binding.test.ts +19 -0
- package/src/channel.test.ts +121 -7
- package/src/channel.ts +55 -15
- package/src/io.test.ts +97 -51
- package/src/io.ts +95 -50
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
|
-
|
|
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
|
|
983
|
-
|
|
984
|
-
if (
|
|
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<
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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('
|
|
41
|
-
expect(await
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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('
|
|
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
|
-
|
|
61
|
-
expect(await
|
|
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('
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
83
|
-
await expect(esploraWriteProofCheck({ fetch:
|
|
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:
|
|
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
|
-
/**
|
|
8
|
-
export interface
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
value
|
|
12
|
-
|
|
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
|
-
/**
|
|
16
|
-
|
|
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
|
-
|
|
32
|
+
cache?: WriteProofCache;
|
|
24
33
|
}
|
|
25
34
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
37
|
-
if (
|
|
38
|
-
|
|
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
|
|
82
|
+
return 'unanswered';
|
|
41
83
|
}
|
|
42
84
|
})();
|
|
43
|
-
cache?.set(
|
|
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
|
|
50
|
-
*
|
|
51
|
-
* the
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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?: '
|
|
137
|
+
opts: EsploraOptions & { unanswered?: 'throw' | 'false' } = {}
|
|
93
138
|
): VerifyOnChain {
|
|
94
|
-
const
|
|
139
|
+
const cache = opts.cache ?? new Map();
|
|
95
140
|
return async (proof, address) => {
|
|
96
|
-
const r = await writeProofOnChain(proof, address, { ...opts,
|
|
141
|
+
const r = await writeProofOnChain(proof, address, { ...opts, cache });
|
|
97
142
|
if (r === 'no-answer') {
|
|
98
|
-
if (opts.unanswered === '
|
|
99
|
-
|
|
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
|
};
|