@jterrazz/attestation 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/node.d.cts CHANGED
@@ -19,6 +19,16 @@ declare function upgradeProof(otsBytes: Uint8Array): Promise<{
19
19
  }>;
20
20
  //#endregion
21
21
  //#region src/ots/verify.d.ts
22
+ /**
23
+ * Options for {@link verifyOts}.
24
+ *
25
+ * `explorerUrl` points the Bitcoin block lookup at an alternative Esplora-
26
+ * compatible endpoint (e.g. a self-hosted explorer, or a test stub). When
27
+ * absent the library's default public source is used.
28
+ */
29
+ type OtsVerifyOptions = {
30
+ explorerUrl?: string;
31
+ };
22
32
  type OtsVerifyOk = {
23
33
  ok: true;
24
34
  bitcoinBlockTime: Date;
@@ -33,11 +43,13 @@ type OtsVerifyResult = OtsVerifyFail | OtsVerifyOk;
33
43
  * Verify an OTS proof attests the given digest, and return the Bitcoin block
34
44
  * attestation time if present.
35
45
  *
36
- * Network: this calls a public Bitcoin block-info source via the OTS library
37
- * to validate the Merkle path. In production-paranoid mode you should run your
38
- * own Bitcoin node and pass it explicitly (future enhancement).
46
+ * Network: this calls a Bitcoin block-info source via the OTS library to
47
+ * validate the Merkle path the library's default public source, or the
48
+ * Esplora-compatible endpoint given in `options.explorerUrl`. In
49
+ * production-paranoid mode you should run your own Bitcoin node and point
50
+ * the explorer option at it.
39
51
  */
40
- declare function verifyOts(digest: Uint8Array, otsBytes: Uint8Array): Promise<OtsVerifyResult>;
52
+ declare function verifyOts(digest: Uint8Array, otsBytes: Uint8Array, options?: OtsVerifyOptions): Promise<OtsVerifyResult>;
41
53
  //#endregion
42
54
  //#region src/eth/sign-flow.d.ts
43
55
  type SignFlowOptions = {
@@ -83,5 +95,5 @@ type BatchSignFlowResult = {
83
95
  };
84
96
  declare function signBatchViaBrowser(opts: BatchSignFlowOptions): Promise<BatchSignFlowResult>;
85
97
  //#endregion
86
- export { type BatchSignEntry, type BatchSignFlowOptions, type BatchSignFlowResult, type BatchSignature, type OtsVerifyFail, type OtsVerifyOk, type OtsVerifyResult, type SignFlowOptions, type SignFlowResult, signBatchViaBrowser, signViaBrowser, stampDigest, upgradeProof, verifyOts };
98
+ export { type BatchSignEntry, type BatchSignFlowOptions, type BatchSignFlowResult, type BatchSignature, type OtsVerifyFail, type OtsVerifyOk, type OtsVerifyOptions, type OtsVerifyResult, type SignFlowOptions, type SignFlowResult, signBatchViaBrowser, signViaBrowser, stampDigest, upgradeProof, verifyOts };
87
99
  //# sourceMappingURL=node.d.cts.map
package/dist/node.d.ts CHANGED
@@ -19,6 +19,16 @@ declare function upgradeProof(otsBytes: Uint8Array): Promise<{
19
19
  }>;
20
20
  //#endregion
21
21
  //#region src/ots/verify.d.ts
22
+ /**
23
+ * Options for {@link verifyOts}.
24
+ *
25
+ * `explorerUrl` points the Bitcoin block lookup at an alternative Esplora-
26
+ * compatible endpoint (e.g. a self-hosted explorer, or a test stub). When
27
+ * absent the library's default public source is used.
28
+ */
29
+ type OtsVerifyOptions = {
30
+ explorerUrl?: string;
31
+ };
22
32
  type OtsVerifyOk = {
23
33
  ok: true;
24
34
  bitcoinBlockTime: Date;
@@ -33,11 +43,13 @@ type OtsVerifyResult = OtsVerifyFail | OtsVerifyOk;
33
43
  * Verify an OTS proof attests the given digest, and return the Bitcoin block
34
44
  * attestation time if present.
35
45
  *
36
- * Network: this calls a public Bitcoin block-info source via the OTS library
37
- * to validate the Merkle path. In production-paranoid mode you should run your
38
- * own Bitcoin node and pass it explicitly (future enhancement).
46
+ * Network: this calls a Bitcoin block-info source via the OTS library to
47
+ * validate the Merkle path the library's default public source, or the
48
+ * Esplora-compatible endpoint given in `options.explorerUrl`. In
49
+ * production-paranoid mode you should run your own Bitcoin node and point
50
+ * the explorer option at it.
39
51
  */
40
- declare function verifyOts(digest: Uint8Array, otsBytes: Uint8Array): Promise<OtsVerifyResult>;
52
+ declare function verifyOts(digest: Uint8Array, otsBytes: Uint8Array, options?: OtsVerifyOptions): Promise<OtsVerifyResult>;
41
53
  //#endregion
42
54
  //#region src/eth/sign-flow.d.ts
43
55
  type SignFlowOptions = {
@@ -83,5 +95,5 @@ type BatchSignFlowResult = {
83
95
  };
84
96
  declare function signBatchViaBrowser(opts: BatchSignFlowOptions): Promise<BatchSignFlowResult>;
85
97
  //#endregion
86
- export { type BatchSignEntry, type BatchSignFlowOptions, type BatchSignFlowResult, type BatchSignature, type OtsVerifyFail, type OtsVerifyOk, type OtsVerifyResult, type SignFlowOptions, type SignFlowResult, signBatchViaBrowser, signViaBrowser, stampDigest, upgradeProof, verifyOts };
98
+ export { type BatchSignEntry, type BatchSignFlowOptions, type BatchSignFlowResult, type BatchSignature, type OtsVerifyFail, type OtsVerifyOk, type OtsVerifyOptions, type OtsVerifyResult, type SignFlowOptions, type SignFlowResult, signBatchViaBrowser, signViaBrowser, stampDigest, upgradeProof, verifyOts };
87
99
  //# sourceMappingURL=node.d.ts.map
@@ -61,11 +61,13 @@ const { DetachedTimestampFile, Ops } = javascript_opentimestamps.default;
61
61
  * Verify an OTS proof attests the given digest, and return the Bitcoin block
62
62
  * attestation time if present.
63
63
  *
64
- * Network: this calls a public Bitcoin block-info source via the OTS library
65
- * to validate the Merkle path. In production-paranoid mode you should run your
66
- * own Bitcoin node and pass it explicitly (future enhancement).
64
+ * Network: this calls a Bitcoin block-info source via the OTS library to
65
+ * validate the Merkle path the library's default public source, or the
66
+ * Esplora-compatible endpoint given in `options.explorerUrl`. In
67
+ * production-paranoid mode you should run your own Bitcoin node and point
68
+ * the explorer option at it.
67
69
  */
68
- async function verifyOts(digest, otsBytes) {
70
+ async function verifyOts(digest, otsBytes, options) {
69
71
  if (digest.length !== 32) return {
70
72
  details: `Expected 32-byte digest, got ${digest.length}`,
71
73
  ok: false,
@@ -83,9 +85,18 @@ async function verifyOts(digest, otsBytes) {
83
85
  reason: "invalid-proof"
84
86
  };
85
87
  }
88
+ const verifyOptions = options?.explorerUrl ? {
89
+ esplora: {
90
+ timeout: 1e4,
91
+ url: options.explorerUrl
92
+ },
93
+ ignore_bitcoin_node: true,
94
+ ignoreBitcoinNode: true
95
+ } : void 0;
86
96
  let attestations;
87
97
  try {
88
- attestations = await javascript_opentimestamps.default.verify(detached, original);
98
+ const ots = javascript_opentimestamps.default;
99
+ attestations = verifyOptions ? await ots.verify(detached, original, verifyOptions) : await ots.verify(detached, original);
89
100
  } catch (error) {
90
101
  return {
91
102
  details: error.message,
@@ -1 +1 @@
1
- {"version":3,"file":"sign-flow.cjs","names":["OpenTimestamps","DetachedTimestampFile","Ops","OpenTimestamps","ATTESTATION_DOMAIN_V1","ATTESTATION_PRIMARY_TYPE","ATTESTATION_TYPES_V1"],"sources":["../src/ots/stamp.ts","../src/ots/verify.ts","../src/eth/sign-page-html.ts","../src/eth/sign-flow.ts"],"sourcesContent":["import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n stamp: (detached: unknown) => Promise<void>;\n upgrade: (detached: unknown) => Promise<boolean>;\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\n/**\n * Submit a SHA-256 digest to OpenTimestamps calendars and return the proof bytes.\n *\n * The returned proof initially contains only calendar attestations. Run upgrade()\n * after ~24h to anchor it in a Bitcoin block.\n */\nexport async function stampDigest(digest: Uint8Array): Promise<Uint8Array> {\n if (digest.length !== 32) {\n throw new Error(`Expected 32-byte SHA-256 digest, got ${digest.length} bytes`);\n }\n\n const detached = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n await (OpenTimestamps as unknown as { stamp: (d: unknown) => Promise<void> }).stamp(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return new Uint8Array(bytes as Buffer);\n}\n\n/**\n * Try to upgrade a calendar-only proof to a Bitcoin-anchored proof.\n *\n * Returns the (possibly upgraded) proof bytes plus a flag indicating whether\n * the upgrade actually attached a Bitcoin attestation this time.\n */\nexport async function upgradeProof(\n otsBytes: Uint8Array,\n): Promise<{ bytes: Uint8Array; upgraded: boolean }> {\n const detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n const upgraded = await (\n OpenTimestamps as unknown as { upgrade: (d: unknown) => Promise<boolean> }\n ).upgrade(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return { bytes: new Uint8Array(bytes as Buffer), upgraded };\n}\n","import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\nexport type OtsVerifyOk = {\n ok: true;\n bitcoinBlockTime: Date;\n};\n\nexport type OtsVerifyFail = {\n ok: false;\n reason: 'digest-mismatch' | 'invalid-proof' | 'pending-bitcoin';\n details?: string;\n};\n\nexport type OtsVerifyResult = OtsVerifyFail | OtsVerifyOk;\n\n/**\n * Verify an OTS proof attests the given digest, and return the Bitcoin block\n * attestation time if present.\n *\n * Network: this calls a public Bitcoin block-info source via the OTS library\n * to validate the Merkle path. In production-paranoid mode you should run your\n * own Bitcoin node and pass it explicitly (future enhancement).\n */\nexport async function verifyOts(\n digest: Uint8Array,\n otsBytes: Uint8Array,\n): Promise<OtsVerifyResult> {\n if (digest.length !== 32) {\n return {\n details: `Expected 32-byte digest, got ${digest.length}`,\n ok: false,\n reason: 'digest-mismatch',\n };\n }\n\n let detached: unknown;\n let original: unknown;\n try {\n detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n original = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n let attestations: Record<string, { timestamp: number }>;\n try {\n attestations = await (\n OpenTimestamps as unknown as {\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n }\n ).verify(detached, original);\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n const bitcoin = attestations['bitcoin'];\n if (bitcoin === undefined) {\n return { ok: false, reason: 'pending-bitcoin' };\n }\n\n return {\n bitcoinBlockTime: new Date(bitcoin.timestamp * 1000),\n ok: true,\n };\n}\n","/**\n * Self-contained HTML page served by the local CLI server. Opens MetaMask\n * (or any window.ethereum provider), requests EIP-712 v4 signature, POSTs\n * the result back to the localhost callback.\n *\n * No bundler, no external script — just a string template. Keeping this\n * pure-string makes it trivial to audit what gets shown to the wallet.\n */\nexport function buildSignPageHtml(typedDataJson: string): string {\n // TypedDataJson MUST already be a JSON string of EIP-712 v4 typed data\n // (with `domain`, `types`, `primaryType`, `message`).\n const escaped = typedDataJson\n .replace(/\\\\/g, String.raw`\\\\`)\n .replace(/`/g, '\\\\`')\n .replace(/<\\/script>/gi, String.raw`<\\/script>`);\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<title>Sign attestation</title>\n<style>\n body { font-family: system-ui, sans-serif; max-width: 640px; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }\n button { padding: 0.75rem 1.5rem; font-size: 1rem; cursor: pointer; }\n pre { background: #f4f4f4; padding: 1rem; overflow-x: auto; font-size: 0.85rem; }\n .ok { color: #0a7d22; }\n .err { color: #b00020; white-space: pre-wrap; }\n</style>\n</head>\n<body>\n<h1>Sign attestation</h1>\n<p>Review the structured data below, then click <strong>Sign</strong>. Your wallet will show the same fields.</p>\n<pre id=\"preview\"></pre>\n<p><button id=\"sign\">Sign with wallet</button></p>\n<p id=\"status\"></p>\n<script>\n const TYPED_DATA = JSON.parse(\\`${escaped}\\`);\n document.getElementById('preview').textContent = JSON.stringify(TYPED_DATA.message, null, 2);\n\n document.getElementById('sign').addEventListener('click', async () => {\n const status = document.getElementById('status');\n status.className = '';\n status.textContent = 'Requesting account…';\n\n if (!window.ethereum) {\n status.className = 'err';\n status.textContent = 'No window.ethereum provider found. Install MetaMask (or another wallet) and reload.';\n return;\n }\n\n try {\n const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });\n const signer = accounts[0];\n\n status.textContent = 'Awaiting wallet confirmation…';\n const signature = await window.ethereum.request({\n method: 'eth_signTypedData_v4',\n params: [signer, JSON.stringify(TYPED_DATA)],\n });\n\n status.textContent = 'Submitting signature…';\n const res = await fetch('/done', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ signature, signerAddress: signer }),\n });\n if (!res.ok) throw new Error('Server rejected the signature: HTTP ' + res.status);\n\n status.className = 'ok';\n status.textContent = 'Signed. You can close this tab.';\n document.getElementById('sign').disabled = true;\n } catch (err) {\n status.className = 'err';\n status.textContent = 'Failed: ' + (err && err.message ? err.message : String(err));\n }\n });\n</script>\n</body>\n</html>`;\n}\n","import { createServer as createHttpServer } from 'node:http';\nimport { type AddressInfo, createServer } from 'node:net';\n\nimport {\n ATTESTATION_DOMAIN_V1,\n ATTESTATION_PRIMARY_TYPE,\n ATTESTATION_TYPES_V1,\n type AttestationMessage,\n} from '../core/eip712-schema.js';\nimport { buildSignPageHtml } from './sign-page-html.js';\n\nexport type SignFlowOptions = {\n message: AttestationMessage;\n onUrlReady?: (url: string) => Promise<void> | void;\n timeoutMs?: number;\n};\n\nexport type SignFlowResult = {\n signature: `0x${string}`;\n signerAddress: `0x${string}`;\n};\n\nconst DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;\n\n/**\n * Spin up a one-shot localhost HTTP server, open the browser, wait for the user\n * to sign with their wallet, return the signature.\n *\n * The local server only accepts loopback requests (127.0.0.1).\n */\nexport async function signViaBrowser(opts: SignFlowOptions): Promise<SignFlowResult> {\n const message = opts.message;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const typedData = {\n domain: ATTESTATION_DOMAIN_V1,\n message: serializeMessageForBrowser(message),\n primaryType: ATTESTATION_PRIMARY_TYPE,\n types: { EIP712Domain: domainTypes(), ...ATTESTATION_TYPES_V1 },\n };\n const html = buildSignPageHtml(JSON.stringify(typedData));\n\n return new Promise<SignFlowResult>((resolve, reject) => {\n const server = createHttpServer((req, res) => {\n if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n res.end(html);\n return;\n }\n\n if (req.method === 'POST' && req.url === '/done') {\n let body = '';\n req.on('data', (chunk: Buffer) => {\n body += chunk.toString('utf8');\n });\n req.on('end', () => {\n let parsed: { signature: `0x${string}`; signerAddress: `0x${string}` };\n try {\n parsed = JSON.parse(body) as typeof parsed;\n } catch {\n res.writeHead(400);\n res.end('Parse error');\n return; // Keep server running so user can retry\n }\n if (\n typeof parsed.signature !== 'string' ||\n !/^0x[0-9a-fA-F]{130}$/.test(parsed.signature) ||\n typeof parsed.signerAddress !== 'string' ||\n !/^0x[0-9a-fA-F]{40}$/.test(parsed.signerAddress)\n ) {\n res.writeHead(400);\n res.end('Invalid payload');\n return; // Keep server running so user can retry\n }\n res.writeHead(200, { 'content-type': 'text/plain' });\n res.end('OK');\n res.on('finish', () => {\n clearTimeout(timer);\n server.close();\n resolve(parsed);\n });\n });\n return;\n }\n\n res.writeHead(404);\n res.end('Not found');\n });\n\n const timer = setTimeout(() => {\n server.close();\n reject(new Error(`Sign flow timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n\n server.on('error', (err) => {\n clearTimeout(timer);\n reject(err);\n });\n\n server.listen(0, '127.0.0.1', () => {\n const addr = server.address() as AddressInfo;\n const url = `http://127.0.0.1:${addr.port}/`;\n const ready = opts.onUrlReady ?? defaultOpenInBrowser;\n void Promise.resolve(ready(url)).catch((error: unknown) => {\n clearTimeout(timer);\n server.close();\n reject(error);\n });\n });\n });\n}\n\nasync function defaultOpenInBrowser(url: string): Promise<void> {\n const { default: open } = await import('open');\n await open(url);\n}\n\n/**\n * Convert AttestationMessage (with bigint publishedAt) to a JSON-serializable\n * form for the browser. EIP-712 v4 expects numeric strings for uint64.\n */\nfunction serializeMessageForBrowser(message: AttestationMessage): Record<string, unknown> {\n return {\n claims: {\n priorAttestation: message.claims.priorAttestation,\n publishedAt: message.claims.publishedAt.toString(),\n revision: message.claims.revision,\n slug: message.claims.slug,\n },\n schemaVersion: message.schemaVersion,\n subject: message.subject,\n };\n}\n\nfunction domainTypes(): Array<{ name: string; type: string }> {\n return [\n { name: 'name', type: 'string' },\n { name: 'version', type: 'string' },\n { name: 'chainId', type: 'uint256' },\n ];\n}\n\n// Suppress unused-import lint: createServer kept for future net-level helpers.\nvoid createServer;\n"],"mappings":"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"}
1
+ {"version":3,"file":"sign-flow.cjs","names":["OpenTimestamps","DetachedTimestampFile","Ops","OpenTimestamps","ATTESTATION_DOMAIN_V1","ATTESTATION_PRIMARY_TYPE","ATTESTATION_TYPES_V1"],"sources":["../src/ots/stamp.ts","../src/ots/verify.ts","../src/eth/sign-page-html.ts","../src/eth/sign-flow.ts"],"sourcesContent":["import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n stamp: (detached: unknown) => Promise<void>;\n upgrade: (detached: unknown) => Promise<boolean>;\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\n/**\n * Submit a SHA-256 digest to OpenTimestamps calendars and return the proof bytes.\n *\n * The returned proof initially contains only calendar attestations. Run upgrade()\n * after ~24h to anchor it in a Bitcoin block.\n */\nexport async function stampDigest(digest: Uint8Array): Promise<Uint8Array> {\n if (digest.length !== 32) {\n throw new Error(`Expected 32-byte SHA-256 digest, got ${digest.length} bytes`);\n }\n\n const detached = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n await (OpenTimestamps as unknown as { stamp: (d: unknown) => Promise<void> }).stamp(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return new Uint8Array(bytes as Buffer);\n}\n\n/**\n * Try to upgrade a calendar-only proof to a Bitcoin-anchored proof.\n *\n * Returns the (possibly upgraded) proof bytes plus a flag indicating whether\n * the upgrade actually attached a Bitcoin attestation this time.\n */\nexport async function upgradeProof(\n otsBytes: Uint8Array,\n): Promise<{ bytes: Uint8Array; upgraded: boolean }> {\n const detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n const upgraded = await (\n OpenTimestamps as unknown as { upgrade: (d: unknown) => Promise<boolean> }\n ).upgrade(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return { bytes: new Uint8Array(bytes as Buffer), upgraded };\n}\n","import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n verify: (\n detached: unknown,\n original: unknown,\n options?: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\n/**\n * Options for {@link verifyOts}.\n *\n * `explorerUrl` points the Bitcoin block lookup at an alternative Esplora-\n * compatible endpoint (e.g. a self-hosted explorer, or a test stub). When\n * absent the library's default public source is used.\n */\nexport type OtsVerifyOptions = {\n explorerUrl?: string;\n};\n\nexport type OtsVerifyOk = {\n ok: true;\n bitcoinBlockTime: Date;\n};\n\nexport type OtsVerifyFail = {\n ok: false;\n reason: 'digest-mismatch' | 'invalid-proof' | 'pending-bitcoin';\n details?: string;\n};\n\nexport type OtsVerifyResult = OtsVerifyFail | OtsVerifyOk;\n\n/**\n * Verify an OTS proof attests the given digest, and return the Bitcoin block\n * attestation time if present.\n *\n * Network: this calls a Bitcoin block-info source via the OTS library to\n * validate the Merkle path — the library's default public source, or the\n * Esplora-compatible endpoint given in `options.explorerUrl`. In\n * production-paranoid mode you should run your own Bitcoin node and point\n * the explorer option at it.\n */\nexport async function verifyOts(\n digest: Uint8Array,\n otsBytes: Uint8Array,\n options?: OtsVerifyOptions,\n): Promise<OtsVerifyResult> {\n if (digest.length !== 32) {\n return {\n details: `Expected 32-byte digest, got ${digest.length}`,\n ok: false,\n reason: 'digest-mismatch',\n };\n }\n\n let detached: unknown;\n let original: unknown;\n try {\n detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n original = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n // An explicit explorer forces the Esplora path — no local Bitcoin node\n // Probing — so the lookup goes exactly where the caller pointed it.\n // The lib's docs name the flag `ignore_bitcoin_node` but the code checks\n // `ignoreBitcoinNode` — pass both so either side of that drift works.\n // The esplora timeout is documented as seconds but flows straight into\n // Request-promise, which reads milliseconds — so this is 10s, not 10000s.\n const verifyOptions = options?.explorerUrl\n ? {\n esplora: { timeout: 10_000, url: options.explorerUrl },\n ignore_bitcoin_node: true,\n ignoreBitcoinNode: true,\n }\n : undefined;\n\n let attestations: Record<string, { timestamp: number }>;\n try {\n const ots = OpenTimestamps as unknown as {\n verify: (\n detached: unknown,\n original: unknown,\n options?: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n };\n attestations = verifyOptions\n ? await ots.verify(detached, original, verifyOptions)\n : await ots.verify(detached, original);\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n const bitcoin = attestations['bitcoin'];\n if (bitcoin === undefined) {\n return { ok: false, reason: 'pending-bitcoin' };\n }\n\n return {\n bitcoinBlockTime: new Date(bitcoin.timestamp * 1000),\n ok: true,\n };\n}\n","/**\n * Self-contained HTML page served by the local CLI server. Opens MetaMask\n * (or any window.ethereum provider), requests EIP-712 v4 signature, POSTs\n * the result back to the localhost callback.\n *\n * No bundler, no external script — just a string template. Keeping this\n * pure-string makes it trivial to audit what gets shown to the wallet.\n */\nexport function buildSignPageHtml(typedDataJson: string): string {\n // TypedDataJson MUST already be a JSON string of EIP-712 v4 typed data\n // (with `domain`, `types`, `primaryType`, `message`).\n const escaped = typedDataJson\n .replace(/\\\\/g, String.raw`\\\\`)\n .replace(/`/g, '\\\\`')\n .replace(/<\\/script>/gi, String.raw`<\\/script>`);\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<title>Sign attestation</title>\n<style>\n body { font-family: system-ui, sans-serif; max-width: 640px; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }\n button { padding: 0.75rem 1.5rem; font-size: 1rem; cursor: pointer; }\n pre { background: #f4f4f4; padding: 1rem; overflow-x: auto; font-size: 0.85rem; }\n .ok { color: #0a7d22; }\n .err { color: #b00020; white-space: pre-wrap; }\n</style>\n</head>\n<body>\n<h1>Sign attestation</h1>\n<p>Review the structured data below, then click <strong>Sign</strong>. Your wallet will show the same fields.</p>\n<pre id=\"preview\"></pre>\n<p><button id=\"sign\">Sign with wallet</button></p>\n<p id=\"status\"></p>\n<script>\n const TYPED_DATA = JSON.parse(\\`${escaped}\\`);\n document.getElementById('preview').textContent = JSON.stringify(TYPED_DATA.message, null, 2);\n\n document.getElementById('sign').addEventListener('click', async () => {\n const status = document.getElementById('status');\n status.className = '';\n status.textContent = 'Requesting account…';\n\n if (!window.ethereum) {\n status.className = 'err';\n status.textContent = 'No window.ethereum provider found. Install MetaMask (or another wallet) and reload.';\n return;\n }\n\n try {\n const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });\n const signer = accounts[0];\n\n status.textContent = 'Awaiting wallet confirmation…';\n const signature = await window.ethereum.request({\n method: 'eth_signTypedData_v4',\n params: [signer, JSON.stringify(TYPED_DATA)],\n });\n\n status.textContent = 'Submitting signature…';\n const res = await fetch('/done', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ signature, signerAddress: signer }),\n });\n if (!res.ok) throw new Error('Server rejected the signature: HTTP ' + res.status);\n\n status.className = 'ok';\n status.textContent = 'Signed. You can close this tab.';\n document.getElementById('sign').disabled = true;\n } catch (err) {\n status.className = 'err';\n status.textContent = 'Failed: ' + (err && err.message ? err.message : String(err));\n }\n });\n</script>\n</body>\n</html>`;\n}\n","import { createServer as createHttpServer } from 'node:http';\nimport { type AddressInfo, createServer } from 'node:net';\n\nimport {\n ATTESTATION_DOMAIN_V1,\n ATTESTATION_PRIMARY_TYPE,\n ATTESTATION_TYPES_V1,\n type AttestationMessage,\n} from '../core/eip712-schema.js';\nimport { buildSignPageHtml } from './sign-page-html.js';\n\nexport type SignFlowOptions = {\n message: AttestationMessage;\n onUrlReady?: (url: string) => Promise<void> | void;\n timeoutMs?: number;\n};\n\nexport type SignFlowResult = {\n signature: `0x${string}`;\n signerAddress: `0x${string}`;\n};\n\nconst DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;\n\n/**\n * Spin up a one-shot localhost HTTP server, open the browser, wait for the user\n * to sign with their wallet, return the signature.\n *\n * The local server only accepts loopback requests (127.0.0.1).\n */\nexport async function signViaBrowser(opts: SignFlowOptions): Promise<SignFlowResult> {\n const message = opts.message;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const typedData = {\n domain: ATTESTATION_DOMAIN_V1,\n message: serializeMessageForBrowser(message),\n primaryType: ATTESTATION_PRIMARY_TYPE,\n types: { EIP712Domain: domainTypes(), ...ATTESTATION_TYPES_V1 },\n };\n const html = buildSignPageHtml(JSON.stringify(typedData));\n\n return new Promise<SignFlowResult>((resolve, reject) => {\n const server = createHttpServer((req, res) => {\n if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n res.end(html);\n return;\n }\n\n if (req.method === 'POST' && req.url === '/done') {\n let body = '';\n req.on('data', (chunk: Buffer) => {\n body += chunk.toString('utf8');\n });\n req.on('end', () => {\n let parsed: { signature: `0x${string}`; signerAddress: `0x${string}` };\n try {\n parsed = JSON.parse(body) as typeof parsed;\n } catch {\n res.writeHead(400);\n res.end('Parse error');\n return; // Keep server running so user can retry\n }\n if (\n typeof parsed.signature !== 'string' ||\n !/^0x[0-9a-fA-F]{130}$/.test(parsed.signature) ||\n typeof parsed.signerAddress !== 'string' ||\n !/^0x[0-9a-fA-F]{40}$/.test(parsed.signerAddress)\n ) {\n res.writeHead(400);\n res.end('Invalid payload');\n return; // Keep server running so user can retry\n }\n res.writeHead(200, { 'content-type': 'text/plain' });\n res.end('OK');\n res.on('finish', () => {\n clearTimeout(timer);\n server.close();\n resolve(parsed);\n });\n });\n return;\n }\n\n res.writeHead(404);\n res.end('Not found');\n });\n\n const timer = setTimeout(() => {\n server.close();\n reject(new Error(`Sign flow timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n\n server.on('error', (err) => {\n clearTimeout(timer);\n reject(err);\n });\n\n server.listen(0, '127.0.0.1', () => {\n const addr = server.address() as AddressInfo;\n const url = `http://127.0.0.1:${addr.port}/`;\n const ready = opts.onUrlReady ?? defaultOpenInBrowser;\n void Promise.resolve(ready(url)).catch((error: unknown) => {\n clearTimeout(timer);\n server.close();\n reject(error);\n });\n });\n });\n}\n\nasync function defaultOpenInBrowser(url: string): Promise<void> {\n const { default: open } = await import('open');\n await open(url);\n}\n\n/**\n * Convert AttestationMessage (with bigint publishedAt) to a JSON-serializable\n * form for the browser. EIP-712 v4 expects numeric strings for uint64.\n */\nfunction serializeMessageForBrowser(message: AttestationMessage): Record<string, unknown> {\n return {\n claims: {\n priorAttestation: message.claims.priorAttestation,\n publishedAt: message.claims.publishedAt.toString(),\n revision: message.claims.revision,\n slug: message.claims.slug,\n },\n schemaVersion: message.schemaVersion,\n subject: message.subject,\n };\n}\n\nfunction domainTypes(): Array<{ name: string; type: string }> {\n return [\n { name: 'name', type: 'string' },\n { name: 'version', type: 'string' },\n { name: 'chainId', type: 'uint256' },\n ];\n}\n\n// Suppress unused-import lint: createServer kept for future net-level helpers.\nvoid createServer;\n"],"mappings":"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"}
package/dist/sign-flow.js CHANGED
@@ -38,11 +38,13 @@ const { DetachedTimestampFile, Ops } = OpenTimestamps;
38
38
  * Verify an OTS proof attests the given digest, and return the Bitcoin block
39
39
  * attestation time if present.
40
40
  *
41
- * Network: this calls a public Bitcoin block-info source via the OTS library
42
- * to validate the Merkle path. In production-paranoid mode you should run your
43
- * own Bitcoin node and pass it explicitly (future enhancement).
41
+ * Network: this calls a Bitcoin block-info source via the OTS library to
42
+ * validate the Merkle path the library's default public source, or the
43
+ * Esplora-compatible endpoint given in `options.explorerUrl`. In
44
+ * production-paranoid mode you should run your own Bitcoin node and point
45
+ * the explorer option at it.
44
46
  */
45
- async function verifyOts(digest, otsBytes) {
47
+ async function verifyOts(digest, otsBytes, options) {
46
48
  if (digest.length !== 32) return {
47
49
  details: `Expected 32-byte digest, got ${digest.length}`,
48
50
  ok: false,
@@ -60,9 +62,18 @@ async function verifyOts(digest, otsBytes) {
60
62
  reason: "invalid-proof"
61
63
  };
62
64
  }
65
+ const verifyOptions = options?.explorerUrl ? {
66
+ esplora: {
67
+ timeout: 1e4,
68
+ url: options.explorerUrl
69
+ },
70
+ ignore_bitcoin_node: true,
71
+ ignoreBitcoinNode: true
72
+ } : void 0;
63
73
  let attestations;
64
74
  try {
65
- attestations = await OpenTimestamps.verify(detached, original);
75
+ const ots = OpenTimestamps;
76
+ attestations = verifyOptions ? await ots.verify(detached, original, verifyOptions) : await ots.verify(detached, original);
66
77
  } catch (error) {
67
78
  return {
68
79
  details: error.message,
@@ -1 +1 @@
1
- {"version":3,"file":"sign-flow.js","names":["DetachedTimestampFile","Ops","createHttpServer"],"sources":["../src/ots/stamp.ts","../src/ots/verify.ts","../src/eth/sign-page-html.ts","../src/eth/sign-flow.ts"],"sourcesContent":["import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n stamp: (detached: unknown) => Promise<void>;\n upgrade: (detached: unknown) => Promise<boolean>;\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\n/**\n * Submit a SHA-256 digest to OpenTimestamps calendars and return the proof bytes.\n *\n * The returned proof initially contains only calendar attestations. Run upgrade()\n * after ~24h to anchor it in a Bitcoin block.\n */\nexport async function stampDigest(digest: Uint8Array): Promise<Uint8Array> {\n if (digest.length !== 32) {\n throw new Error(`Expected 32-byte SHA-256 digest, got ${digest.length} bytes`);\n }\n\n const detached = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n await (OpenTimestamps as unknown as { stamp: (d: unknown) => Promise<void> }).stamp(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return new Uint8Array(bytes as Buffer);\n}\n\n/**\n * Try to upgrade a calendar-only proof to a Bitcoin-anchored proof.\n *\n * Returns the (possibly upgraded) proof bytes plus a flag indicating whether\n * the upgrade actually attached a Bitcoin attestation this time.\n */\nexport async function upgradeProof(\n otsBytes: Uint8Array,\n): Promise<{ bytes: Uint8Array; upgraded: boolean }> {\n const detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n const upgraded = await (\n OpenTimestamps as unknown as { upgrade: (d: unknown) => Promise<boolean> }\n ).upgrade(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return { bytes: new Uint8Array(bytes as Buffer), upgraded };\n}\n","import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\nexport type OtsVerifyOk = {\n ok: true;\n bitcoinBlockTime: Date;\n};\n\nexport type OtsVerifyFail = {\n ok: false;\n reason: 'digest-mismatch' | 'invalid-proof' | 'pending-bitcoin';\n details?: string;\n};\n\nexport type OtsVerifyResult = OtsVerifyFail | OtsVerifyOk;\n\n/**\n * Verify an OTS proof attests the given digest, and return the Bitcoin block\n * attestation time if present.\n *\n * Network: this calls a public Bitcoin block-info source via the OTS library\n * to validate the Merkle path. In production-paranoid mode you should run your\n * own Bitcoin node and pass it explicitly (future enhancement).\n */\nexport async function verifyOts(\n digest: Uint8Array,\n otsBytes: Uint8Array,\n): Promise<OtsVerifyResult> {\n if (digest.length !== 32) {\n return {\n details: `Expected 32-byte digest, got ${digest.length}`,\n ok: false,\n reason: 'digest-mismatch',\n };\n }\n\n let detached: unknown;\n let original: unknown;\n try {\n detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n original = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n let attestations: Record<string, { timestamp: number }>;\n try {\n attestations = await (\n OpenTimestamps as unknown as {\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n }\n ).verify(detached, original);\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n const bitcoin = attestations['bitcoin'];\n if (bitcoin === undefined) {\n return { ok: false, reason: 'pending-bitcoin' };\n }\n\n return {\n bitcoinBlockTime: new Date(bitcoin.timestamp * 1000),\n ok: true,\n };\n}\n","/**\n * Self-contained HTML page served by the local CLI server. Opens MetaMask\n * (or any window.ethereum provider), requests EIP-712 v4 signature, POSTs\n * the result back to the localhost callback.\n *\n * No bundler, no external script — just a string template. Keeping this\n * pure-string makes it trivial to audit what gets shown to the wallet.\n */\nexport function buildSignPageHtml(typedDataJson: string): string {\n // TypedDataJson MUST already be a JSON string of EIP-712 v4 typed data\n // (with `domain`, `types`, `primaryType`, `message`).\n const escaped = typedDataJson\n .replace(/\\\\/g, String.raw`\\\\`)\n .replace(/`/g, '\\\\`')\n .replace(/<\\/script>/gi, String.raw`<\\/script>`);\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<title>Sign attestation</title>\n<style>\n body { font-family: system-ui, sans-serif; max-width: 640px; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }\n button { padding: 0.75rem 1.5rem; font-size: 1rem; cursor: pointer; }\n pre { background: #f4f4f4; padding: 1rem; overflow-x: auto; font-size: 0.85rem; }\n .ok { color: #0a7d22; }\n .err { color: #b00020; white-space: pre-wrap; }\n</style>\n</head>\n<body>\n<h1>Sign attestation</h1>\n<p>Review the structured data below, then click <strong>Sign</strong>. Your wallet will show the same fields.</p>\n<pre id=\"preview\"></pre>\n<p><button id=\"sign\">Sign with wallet</button></p>\n<p id=\"status\"></p>\n<script>\n const TYPED_DATA = JSON.parse(\\`${escaped}\\`);\n document.getElementById('preview').textContent = JSON.stringify(TYPED_DATA.message, null, 2);\n\n document.getElementById('sign').addEventListener('click', async () => {\n const status = document.getElementById('status');\n status.className = '';\n status.textContent = 'Requesting account…';\n\n if (!window.ethereum) {\n status.className = 'err';\n status.textContent = 'No window.ethereum provider found. Install MetaMask (or another wallet) and reload.';\n return;\n }\n\n try {\n const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });\n const signer = accounts[0];\n\n status.textContent = 'Awaiting wallet confirmation…';\n const signature = await window.ethereum.request({\n method: 'eth_signTypedData_v4',\n params: [signer, JSON.stringify(TYPED_DATA)],\n });\n\n status.textContent = 'Submitting signature…';\n const res = await fetch('/done', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ signature, signerAddress: signer }),\n });\n if (!res.ok) throw new Error('Server rejected the signature: HTTP ' + res.status);\n\n status.className = 'ok';\n status.textContent = 'Signed. You can close this tab.';\n document.getElementById('sign').disabled = true;\n } catch (err) {\n status.className = 'err';\n status.textContent = 'Failed: ' + (err && err.message ? err.message : String(err));\n }\n });\n</script>\n</body>\n</html>`;\n}\n","import { createServer as createHttpServer } from 'node:http';\nimport { type AddressInfo, createServer } from 'node:net';\n\nimport {\n ATTESTATION_DOMAIN_V1,\n ATTESTATION_PRIMARY_TYPE,\n ATTESTATION_TYPES_V1,\n type AttestationMessage,\n} from '../core/eip712-schema.js';\nimport { buildSignPageHtml } from './sign-page-html.js';\n\nexport type SignFlowOptions = {\n message: AttestationMessage;\n onUrlReady?: (url: string) => Promise<void> | void;\n timeoutMs?: number;\n};\n\nexport type SignFlowResult = {\n signature: `0x${string}`;\n signerAddress: `0x${string}`;\n};\n\nconst DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;\n\n/**\n * Spin up a one-shot localhost HTTP server, open the browser, wait for the user\n * to sign with their wallet, return the signature.\n *\n * The local server only accepts loopback requests (127.0.0.1).\n */\nexport async function signViaBrowser(opts: SignFlowOptions): Promise<SignFlowResult> {\n const message = opts.message;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const typedData = {\n domain: ATTESTATION_DOMAIN_V1,\n message: serializeMessageForBrowser(message),\n primaryType: ATTESTATION_PRIMARY_TYPE,\n types: { EIP712Domain: domainTypes(), ...ATTESTATION_TYPES_V1 },\n };\n const html = buildSignPageHtml(JSON.stringify(typedData));\n\n return new Promise<SignFlowResult>((resolve, reject) => {\n const server = createHttpServer((req, res) => {\n if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n res.end(html);\n return;\n }\n\n if (req.method === 'POST' && req.url === '/done') {\n let body = '';\n req.on('data', (chunk: Buffer) => {\n body += chunk.toString('utf8');\n });\n req.on('end', () => {\n let parsed: { signature: `0x${string}`; signerAddress: `0x${string}` };\n try {\n parsed = JSON.parse(body) as typeof parsed;\n } catch {\n res.writeHead(400);\n res.end('Parse error');\n return; // Keep server running so user can retry\n }\n if (\n typeof parsed.signature !== 'string' ||\n !/^0x[0-9a-fA-F]{130}$/.test(parsed.signature) ||\n typeof parsed.signerAddress !== 'string' ||\n !/^0x[0-9a-fA-F]{40}$/.test(parsed.signerAddress)\n ) {\n res.writeHead(400);\n res.end('Invalid payload');\n return; // Keep server running so user can retry\n }\n res.writeHead(200, { 'content-type': 'text/plain' });\n res.end('OK');\n res.on('finish', () => {\n clearTimeout(timer);\n server.close();\n resolve(parsed);\n });\n });\n return;\n }\n\n res.writeHead(404);\n res.end('Not found');\n });\n\n const timer = setTimeout(() => {\n server.close();\n reject(new Error(`Sign flow timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n\n server.on('error', (err) => {\n clearTimeout(timer);\n reject(err);\n });\n\n server.listen(0, '127.0.0.1', () => {\n const addr = server.address() as AddressInfo;\n const url = `http://127.0.0.1:${addr.port}/`;\n const ready = opts.onUrlReady ?? defaultOpenInBrowser;\n void Promise.resolve(ready(url)).catch((error: unknown) => {\n clearTimeout(timer);\n server.close();\n reject(error);\n });\n });\n });\n}\n\nasync function defaultOpenInBrowser(url: string): Promise<void> {\n const { default: open } = await import('open');\n await open(url);\n}\n\n/**\n * Convert AttestationMessage (with bigint publishedAt) to a JSON-serializable\n * form for the browser. EIP-712 v4 expects numeric strings for uint64.\n */\nfunction serializeMessageForBrowser(message: AttestationMessage): Record<string, unknown> {\n return {\n claims: {\n priorAttestation: message.claims.priorAttestation,\n publishedAt: message.claims.publishedAt.toString(),\n revision: message.claims.revision,\n slug: message.claims.slug,\n },\n schemaVersion: message.schemaVersion,\n subject: message.subject,\n };\n}\n\nfunction domainTypes(): Array<{ name: string; type: string }> {\n return [\n { name: 'name', type: 'string' },\n { name: 'version', type: 'string' },\n { name: 'chainId', type: 'uint256' },\n ];\n}\n\n// Suppress unused-import lint: createServer kept for future net-level helpers.\nvoid createServer;\n"],"mappings":"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"}
1
+ {"version":3,"file":"sign-flow.js","names":["DetachedTimestampFile","Ops","createHttpServer"],"sources":["../src/ots/stamp.ts","../src/ots/verify.ts","../src/eth/sign-page-html.ts","../src/eth/sign-flow.ts"],"sourcesContent":["import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n stamp: (detached: unknown) => Promise<void>;\n upgrade: (detached: unknown) => Promise<boolean>;\n verify: (\n detached: unknown,\n original: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\n/**\n * Submit a SHA-256 digest to OpenTimestamps calendars and return the proof bytes.\n *\n * The returned proof initially contains only calendar attestations. Run upgrade()\n * after ~24h to anchor it in a Bitcoin block.\n */\nexport async function stampDigest(digest: Uint8Array): Promise<Uint8Array> {\n if (digest.length !== 32) {\n throw new Error(`Expected 32-byte SHA-256 digest, got ${digest.length} bytes`);\n }\n\n const detached = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n await (OpenTimestamps as unknown as { stamp: (d: unknown) => Promise<void> }).stamp(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return new Uint8Array(bytes as Buffer);\n}\n\n/**\n * Try to upgrade a calendar-only proof to a Bitcoin-anchored proof.\n *\n * Returns the (possibly upgraded) proof bytes plus a flag indicating whether\n * the upgrade actually attached a Bitcoin attestation this time.\n */\nexport async function upgradeProof(\n otsBytes: Uint8Array,\n): Promise<{ bytes: Uint8Array; upgraded: boolean }> {\n const detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n const upgraded = await (\n OpenTimestamps as unknown as { upgrade: (d: unknown) => Promise<boolean> }\n ).upgrade(detached);\n\n const serialize = (detached as { serializeToBytes: () => Buffer | Uint8Array })\n .serializeToBytes;\n const bytes = serialize.call(detached);\n return { bytes: new Uint8Array(bytes as Buffer), upgraded };\n}\n","import OpenTimestamps from 'javascript-opentimestamps';\n\nconst { DetachedTimestampFile, Ops } = OpenTimestamps as {\n DetachedTimestampFile: {\n deserialize: (bytes: Buffer | Uint8Array) => unknown;\n fromHash: (op: unknown, hash: Buffer) => unknown;\n };\n Ops: { OpSHA256: unknown };\n verify: (\n detached: unknown,\n original: unknown,\n options?: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n};\n\n/**\n * Options for {@link verifyOts}.\n *\n * `explorerUrl` points the Bitcoin block lookup at an alternative Esplora-\n * compatible endpoint (e.g. a self-hosted explorer, or a test stub). When\n * absent the library's default public source is used.\n */\nexport type OtsVerifyOptions = {\n explorerUrl?: string;\n};\n\nexport type OtsVerifyOk = {\n ok: true;\n bitcoinBlockTime: Date;\n};\n\nexport type OtsVerifyFail = {\n ok: false;\n reason: 'digest-mismatch' | 'invalid-proof' | 'pending-bitcoin';\n details?: string;\n};\n\nexport type OtsVerifyResult = OtsVerifyFail | OtsVerifyOk;\n\n/**\n * Verify an OTS proof attests the given digest, and return the Bitcoin block\n * attestation time if present.\n *\n * Network: this calls a Bitcoin block-info source via the OTS library to\n * validate the Merkle path — the library's default public source, or the\n * Esplora-compatible endpoint given in `options.explorerUrl`. In\n * production-paranoid mode you should run your own Bitcoin node and point\n * the explorer option at it.\n */\nexport async function verifyOts(\n digest: Uint8Array,\n otsBytes: Uint8Array,\n options?: OtsVerifyOptions,\n): Promise<OtsVerifyResult> {\n if (digest.length !== 32) {\n return {\n details: `Expected 32-byte digest, got ${digest.length}`,\n ok: false,\n reason: 'digest-mismatch',\n };\n }\n\n let detached: unknown;\n let original: unknown;\n try {\n detached = DetachedTimestampFile.deserialize(Buffer.from(otsBytes));\n original = DetachedTimestampFile.fromHash(\n new (Ops.OpSHA256 as new () => unknown)(),\n Buffer.from(digest),\n );\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n // An explicit explorer forces the Esplora path — no local Bitcoin node\n // Probing — so the lookup goes exactly where the caller pointed it.\n // The lib's docs name the flag `ignore_bitcoin_node` but the code checks\n // `ignoreBitcoinNode` — pass both so either side of that drift works.\n // The esplora timeout is documented as seconds but flows straight into\n // Request-promise, which reads milliseconds — so this is 10s, not 10000s.\n const verifyOptions = options?.explorerUrl\n ? {\n esplora: { timeout: 10_000, url: options.explorerUrl },\n ignore_bitcoin_node: true,\n ignoreBitcoinNode: true,\n }\n : undefined;\n\n let attestations: Record<string, { timestamp: number }>;\n try {\n const ots = OpenTimestamps as unknown as {\n verify: (\n detached: unknown,\n original: unknown,\n options?: unknown,\n ) => Promise<Record<string, { timestamp: number }>>;\n };\n attestations = verifyOptions\n ? await ots.verify(detached, original, verifyOptions)\n : await ots.verify(detached, original);\n } catch (error) {\n return { details: (error as Error).message, ok: false, reason: 'invalid-proof' };\n }\n\n const bitcoin = attestations['bitcoin'];\n if (bitcoin === undefined) {\n return { ok: false, reason: 'pending-bitcoin' };\n }\n\n return {\n bitcoinBlockTime: new Date(bitcoin.timestamp * 1000),\n ok: true,\n };\n}\n","/**\n * Self-contained HTML page served by the local CLI server. Opens MetaMask\n * (or any window.ethereum provider), requests EIP-712 v4 signature, POSTs\n * the result back to the localhost callback.\n *\n * No bundler, no external script — just a string template. Keeping this\n * pure-string makes it trivial to audit what gets shown to the wallet.\n */\nexport function buildSignPageHtml(typedDataJson: string): string {\n // TypedDataJson MUST already be a JSON string of EIP-712 v4 typed data\n // (with `domain`, `types`, `primaryType`, `message`).\n const escaped = typedDataJson\n .replace(/\\\\/g, String.raw`\\\\`)\n .replace(/`/g, '\\\\`')\n .replace(/<\\/script>/gi, String.raw`<\\/script>`);\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<title>Sign attestation</title>\n<style>\n body { font-family: system-ui, sans-serif; max-width: 640px; margin: 4rem auto; padding: 0 1rem; line-height: 1.5; }\n button { padding: 0.75rem 1.5rem; font-size: 1rem; cursor: pointer; }\n pre { background: #f4f4f4; padding: 1rem; overflow-x: auto; font-size: 0.85rem; }\n .ok { color: #0a7d22; }\n .err { color: #b00020; white-space: pre-wrap; }\n</style>\n</head>\n<body>\n<h1>Sign attestation</h1>\n<p>Review the structured data below, then click <strong>Sign</strong>. Your wallet will show the same fields.</p>\n<pre id=\"preview\"></pre>\n<p><button id=\"sign\">Sign with wallet</button></p>\n<p id=\"status\"></p>\n<script>\n const TYPED_DATA = JSON.parse(\\`${escaped}\\`);\n document.getElementById('preview').textContent = JSON.stringify(TYPED_DATA.message, null, 2);\n\n document.getElementById('sign').addEventListener('click', async () => {\n const status = document.getElementById('status');\n status.className = '';\n status.textContent = 'Requesting account…';\n\n if (!window.ethereum) {\n status.className = 'err';\n status.textContent = 'No window.ethereum provider found. Install MetaMask (or another wallet) and reload.';\n return;\n }\n\n try {\n const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });\n const signer = accounts[0];\n\n status.textContent = 'Awaiting wallet confirmation…';\n const signature = await window.ethereum.request({\n method: 'eth_signTypedData_v4',\n params: [signer, JSON.stringify(TYPED_DATA)],\n });\n\n status.textContent = 'Submitting signature…';\n const res = await fetch('/done', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ signature, signerAddress: signer }),\n });\n if (!res.ok) throw new Error('Server rejected the signature: HTTP ' + res.status);\n\n status.className = 'ok';\n status.textContent = 'Signed. You can close this tab.';\n document.getElementById('sign').disabled = true;\n } catch (err) {\n status.className = 'err';\n status.textContent = 'Failed: ' + (err && err.message ? err.message : String(err));\n }\n });\n</script>\n</body>\n</html>`;\n}\n","import { createServer as createHttpServer } from 'node:http';\nimport { type AddressInfo, createServer } from 'node:net';\n\nimport {\n ATTESTATION_DOMAIN_V1,\n ATTESTATION_PRIMARY_TYPE,\n ATTESTATION_TYPES_V1,\n type AttestationMessage,\n} from '../core/eip712-schema.js';\nimport { buildSignPageHtml } from './sign-page-html.js';\n\nexport type SignFlowOptions = {\n message: AttestationMessage;\n onUrlReady?: (url: string) => Promise<void> | void;\n timeoutMs?: number;\n};\n\nexport type SignFlowResult = {\n signature: `0x${string}`;\n signerAddress: `0x${string}`;\n};\n\nconst DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;\n\n/**\n * Spin up a one-shot localhost HTTP server, open the browser, wait for the user\n * to sign with their wallet, return the signature.\n *\n * The local server only accepts loopback requests (127.0.0.1).\n */\nexport async function signViaBrowser(opts: SignFlowOptions): Promise<SignFlowResult> {\n const message = opts.message;\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\n const typedData = {\n domain: ATTESTATION_DOMAIN_V1,\n message: serializeMessageForBrowser(message),\n primaryType: ATTESTATION_PRIMARY_TYPE,\n types: { EIP712Domain: domainTypes(), ...ATTESTATION_TYPES_V1 },\n };\n const html = buildSignPageHtml(JSON.stringify(typedData));\n\n return new Promise<SignFlowResult>((resolve, reject) => {\n const server = createHttpServer((req, res) => {\n if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n res.end(html);\n return;\n }\n\n if (req.method === 'POST' && req.url === '/done') {\n let body = '';\n req.on('data', (chunk: Buffer) => {\n body += chunk.toString('utf8');\n });\n req.on('end', () => {\n let parsed: { signature: `0x${string}`; signerAddress: `0x${string}` };\n try {\n parsed = JSON.parse(body) as typeof parsed;\n } catch {\n res.writeHead(400);\n res.end('Parse error');\n return; // Keep server running so user can retry\n }\n if (\n typeof parsed.signature !== 'string' ||\n !/^0x[0-9a-fA-F]{130}$/.test(parsed.signature) ||\n typeof parsed.signerAddress !== 'string' ||\n !/^0x[0-9a-fA-F]{40}$/.test(parsed.signerAddress)\n ) {\n res.writeHead(400);\n res.end('Invalid payload');\n return; // Keep server running so user can retry\n }\n res.writeHead(200, { 'content-type': 'text/plain' });\n res.end('OK');\n res.on('finish', () => {\n clearTimeout(timer);\n server.close();\n resolve(parsed);\n });\n });\n return;\n }\n\n res.writeHead(404);\n res.end('Not found');\n });\n\n const timer = setTimeout(() => {\n server.close();\n reject(new Error(`Sign flow timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n\n server.on('error', (err) => {\n clearTimeout(timer);\n reject(err);\n });\n\n server.listen(0, '127.0.0.1', () => {\n const addr = server.address() as AddressInfo;\n const url = `http://127.0.0.1:${addr.port}/`;\n const ready = opts.onUrlReady ?? defaultOpenInBrowser;\n void Promise.resolve(ready(url)).catch((error: unknown) => {\n clearTimeout(timer);\n server.close();\n reject(error);\n });\n });\n });\n}\n\nasync function defaultOpenInBrowser(url: string): Promise<void> {\n const { default: open } = await import('open');\n await open(url);\n}\n\n/**\n * Convert AttestationMessage (with bigint publishedAt) to a JSON-serializable\n * form for the browser. EIP-712 v4 expects numeric strings for uint64.\n */\nfunction serializeMessageForBrowser(message: AttestationMessage): Record<string, unknown> {\n return {\n claims: {\n priorAttestation: message.claims.priorAttestation,\n publishedAt: message.claims.publishedAt.toString(),\n revision: message.claims.revision,\n slug: message.claims.slug,\n },\n schemaVersion: message.schemaVersion,\n subject: message.subject,\n };\n}\n\nfunction domainTypes(): Array<{ name: string; type: string }> {\n return [\n { name: 'name', type: 'string' },\n { name: 'version', type: 'string' },\n { name: 'chainId', type: 'uint256' },\n ];\n}\n\n// Suppress unused-import lint: createServer kept for future net-level helpers.\nvoid createServer;\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/attestation",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Cryptographic attestation for articles — EIP-712 signature anchored to Bitcoin via OpenTimestamps.",
5
5
  "license": "MIT",
6
6
  "author": "Jean-Baptiste Terrazzoni <contact@jterrazz.com>",