@provenonce/beats-client 0.2.0 → 0.3.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.
Files changed (4) hide show
  1. package/README.md +50 -50
  2. package/index.d.ts +148 -96
  3. package/index.mjs +476 -178
  4. package/package.json +20 -20
package/README.md CHANGED
@@ -1,50 +1,50 @@
1
- # @provenonce/beats-client
2
-
3
- Minimal client for the public Beats service.
4
-
5
- ## Install
6
-
7
- From npm (after publish):
8
-
9
- ```bash
10
- npm i @provenonce/beats-client
11
- ```
12
-
13
- Local repo path (pre-publish):
14
-
15
- ```bash
16
- npm i ./sdk/beats-client
17
- ```
18
-
19
- ## Usage
20
-
21
- ```js
22
- import { createBeatsClient } from '@provenonce/beats-client';
23
-
24
- const beats = createBeatsClient();
25
-
26
- const anchor = await beats.getAnchor();
27
- const receipt = await beats.timestampHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
28
- const receiptValid = await beats.verifyReceipt(receipt);
29
- const anchorValid = await beats.verifyAnchor(anchor);
30
- const onChain = await beats.verifyOnChain(receipt.on_chain.tx_signature, { cluster: 'devnet' });
31
-
32
- // Auto-verify anchor receipt:
33
- const verifiedAnchor = await beats.getAnchor({ verify: true });
34
- ```
35
-
36
- ## Endpoints wrapped
37
-
38
- - `GET /api/health`
39
- - `GET /api/v1/beat/anchor`
40
- - `GET /api/v1/beat/key`
41
- - `POST /api/v1/beat/verify`
42
- - `POST /api/v1/beat/timestamp`
43
-
44
- ## Helpers
45
-
46
- - `verifyReceipt(response)` - offline Ed25519 verification of timestamp/anchor receipts.
47
- - `verifyAnchor(anchorResponse)` - explicit offline verification helper for anchor responses.
48
- - `verifyOnChain(txSignature, { cluster | rpcUrl })` - direct Solana RPC status check.
49
- - `getAnchor({ verify: true })` - fetch anchor and verify attached receipt in one call.
50
-
1
+ # @provenonce/beats-client
2
+
3
+ Minimal client for the public Beats service.
4
+
5
+ ## Install
6
+
7
+ From npm (after publish):
8
+
9
+ ```bash
10
+ npm i @provenonce/beats-client
11
+ ```
12
+
13
+ Local repo path (pre-publish):
14
+
15
+ ```bash
16
+ npm i ./sdk/beats-client
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```js
22
+ import { createBeatsClient } from '@provenonce/beats-client';
23
+
24
+ const beats = createBeatsClient();
25
+
26
+ const anchor = await beats.getAnchor();
27
+ const receipt = await beats.timestampHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
28
+ const receiptValid = await beats.verifyReceipt(receipt);
29
+ const anchorValid = await beats.verifyAnchor(anchor);
30
+ const onChain = await beats.verifyOnChain(receipt.on_chain.tx_signature, { cluster: 'devnet' });
31
+
32
+ // Auto-verify anchor receipt:
33
+ const verifiedAnchor = await beats.getAnchor({ verify: true });
34
+ ```
35
+
36
+ ## Endpoints wrapped
37
+
38
+ - `GET /api/health`
39
+ - `GET /api/v1/beat/anchor`
40
+ - `GET /api/v1/beat/key`
41
+ - `POST /api/v1/beat/verify`
42
+ - `POST /api/v1/beat/timestamp`
43
+
44
+ ## Helpers
45
+
46
+ - `verifyReceipt(response)` - offline Ed25519 verification of timestamp/anchor receipts.
47
+ - `verifyAnchor(anchorResponse)` - explicit offline verification helper for anchor responses.
48
+ - `verifyOnChain(txSignature, { cluster | rpcUrl })` - direct Solana RPC status check.
49
+ - `getAnchor({ verify: true })` - fetch anchor and verify attached receipt in one call.
50
+
package/index.d.ts CHANGED
@@ -1,96 +1,148 @@
1
- export interface BeatsClientOptions {
2
- baseUrl?: string;
3
- fetchImpl?: typeof fetch;
4
- }
5
-
6
- export interface VerifyOnChainOptions {
7
- rpcUrl?: string;
8
- cluster?: 'devnet' | 'testnet' | 'mainnet-beta';
9
- }
10
-
11
- export interface VerifyOnChainResult {
12
- found: boolean;
13
- confirmationStatus: string | null;
14
- finalized: boolean;
15
- slot: number | null;
16
- }
17
-
18
- export interface AnchorOptions {
19
- verify?: boolean;
20
- }
21
-
22
- export interface HealthResponse {
23
- status?: string;
24
- [key: string]: unknown;
25
- }
26
-
27
- export interface BeatAnchor {
28
- beat_index: number;
29
- hash: string;
30
- prev_hash: string;
31
- utc: number;
32
- difficulty: number;
33
- epoch: number;
34
- }
35
-
36
- export interface ReceiptEnvelope {
37
- signature: string;
38
- public_key: string;
39
- }
40
-
41
- export interface AnchorResponse {
42
- anchor: BeatAnchor | null;
43
- on_chain: {
44
- tx_signature: string | null;
45
- explorer_url: string | null;
46
- anchored?: boolean;
47
- };
48
- receipt?: ReceiptEnvelope;
49
- anchor_interval_sec?: number;
50
- next_anchor_at?: string;
51
- _verified_receipt?: boolean;
52
- _note?: string;
53
- _info?: string;
54
- }
55
-
56
- export interface TimestampPayload {
57
- hash: string;
58
- anchor_index: number;
59
- anchor_hash: string;
60
- utc: number;
61
- tx_signature: string;
62
- }
63
-
64
- export interface TimestampResponse {
65
- timestamp: TimestampPayload;
66
- on_chain: {
67
- tx_signature: string;
68
- explorer_url: string;
69
- };
70
- receipt: ReceiptEnvelope;
71
- tier?: 'free' | 'pro';
72
- _note?: string;
73
- }
74
-
75
- export interface VerifyApiResponse {
76
- ok?: boolean;
77
- mode?: 'beat' | 'chain' | 'proof';
78
- valid?: boolean;
79
- error?: string;
80
- reason?: string;
81
- [key: string]: unknown;
82
- }
83
-
84
- export interface BeatsClient {
85
- getHealth(): Promise<HealthResponse>;
86
- getAnchor(opts?: AnchorOptions): Promise<AnchorResponse>;
87
- getKey(): Promise<{ public_key_base58: string; public_key_hex: string; algorithm: string; purpose?: string; _note?: string }>;
88
- verify(payload: unknown): Promise<VerifyApiResponse>;
89
- timestampHash(hash: string): Promise<TimestampResponse>;
90
- verifyReceipt(response: AnchorResponse | TimestampResponse | { payload: Record<string, unknown>; signature: string; public_key: string }): Promise<boolean>;
91
- verifyAnchor(anchorResponse: AnchorResponse): Promise<boolean>;
92
- verifyOnChain(txSignature: string, opts?: VerifyOnChainOptions): Promise<VerifyOnChainResult>;
93
- }
94
-
95
- export declare function createBeatsClient(options?: BeatsClientOptions): BeatsClient;
96
-
1
+ export interface ContinuityState {
2
+ beat_index: number;
3
+ hash: string;
4
+ agent_id: string | null;
5
+ }
6
+
7
+ export interface BeatsClientOptions {
8
+ baseUrl?: string;
9
+ fetchImpl?: typeof fetch;
10
+ /** Pin a known public key for receipt verification (B-1). Hex or base58. */
11
+ pinnedPublicKey?: string | null;
12
+ /** Request timeout in milliseconds (B-5). Default: 30000. */
13
+ timeoutMs?: number;
14
+ /** Callback when continuity state changes. Persist this for restart recovery. */
15
+ onStateChange?: (state: ContinuityState) => void;
16
+ /** Load persisted continuity state on startup. */
17
+ loadState?: () => ContinuityState | null;
18
+ /** Agent ID for scoping persisted state. */
19
+ agentId?: string | null;
20
+ }
21
+
22
+ export interface VerifyOnChainOptions {
23
+ rpcUrl?: string;
24
+ cluster?: 'devnet' | 'testnet' | 'mainnet-beta';
25
+ /** If provided, fetches the full transaction and verifies SPL Memo content matches (B-4). */
26
+ expectedPayload?: Record<string, unknown>;
27
+ }
28
+
29
+ export interface VerifyOnChainResult {
30
+ found: boolean;
31
+ confirmationStatus: string | null;
32
+ finalized: boolean;
33
+ slot: number | null;
34
+ /** True if memo content matches expectedPayload (B-4). Only present when expectedPayload is provided. */
35
+ memoVerified?: boolean;
36
+ /** Parsed memo data from the transaction (B-4). */
37
+ memoData?: Record<string, unknown>;
38
+ /** Reason if memoVerified is false. */
39
+ reason?: string;
40
+ }
41
+
42
+ export interface VerifyReceiptOptions {
43
+ /** Override key for this specific verification. Hex or base58. */
44
+ publicKey?: string;
45
+ }
46
+
47
+ export interface AnchorOptions {
48
+ /** Verify receipt signature against pinned/cached key (B-1). */
49
+ verify?: boolean;
50
+ /** Recompute anchor hash locally via SHA-256 chain (B-3, Node.js only). */
51
+ recompute?: boolean;
52
+ }
53
+
54
+ export interface HealthResponse {
55
+ status?: string;
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ export interface BeatAnchor {
60
+ beat_index: number;
61
+ hash: string;
62
+ prev_hash: string;
63
+ utc: number;
64
+ difficulty: number;
65
+ epoch: number;
66
+ tx_signature: string;
67
+ }
68
+
69
+ export interface ReceiptEnvelope {
70
+ signature: string;
71
+ public_key: string;
72
+ }
73
+
74
+ export interface AnchorResponse {
75
+ anchor: BeatAnchor | null;
76
+ on_chain: {
77
+ tx_signature: string | null;
78
+ explorer_url: string | null;
79
+ anchored?: boolean;
80
+ };
81
+ receipt?: ReceiptEnvelope;
82
+ anchor_interval_sec?: number;
83
+ next_anchor_at?: string;
84
+ _verified_receipt?: boolean;
85
+ _verified_hash?: boolean;
86
+ _note?: string;
87
+ _info?: string;
88
+ }
89
+
90
+ export interface TimestampPayload {
91
+ hash: string;
92
+ anchor_index: number;
93
+ anchor_hash: string;
94
+ utc: number;
95
+ tx_signature: string;
96
+ }
97
+
98
+ export interface TimestampResponse {
99
+ timestamp: TimestampPayload;
100
+ on_chain: {
101
+ tx_signature: string;
102
+ explorer_url: string;
103
+ };
104
+ receipt: ReceiptEnvelope;
105
+ tier?: 'free' | 'pro';
106
+ _note?: string;
107
+ }
108
+
109
+ export interface VerifyApiResponse {
110
+ ok?: boolean;
111
+ mode?: 'beat' | 'chain' | 'proof';
112
+ valid?: boolean;
113
+ error?: string;
114
+ reason?: string;
115
+ [key: string]: unknown;
116
+ }
117
+
118
+ export interface BeatsClient {
119
+ getHealth(): Promise<HealthResponse>;
120
+ getAnchor(opts?: AnchorOptions): Promise<AnchorResponse>;
121
+ getKey(): Promise<{ public_key_base58: string; public_key_hex: string; algorithm: string; purpose?: string; _note?: string }>;
122
+ verify(payload: unknown): Promise<VerifyApiResponse>;
123
+ timestampHash(hash: string): Promise<TimestampResponse>;
124
+
125
+ /** Verify receipt signature against pinned/cached/fetched key (B-1). Never uses response-embedded key. */
126
+ verifyReceipt(response: AnchorResponse | TimestampResponse | { payload: Record<string, unknown>; signature: string; public_key: string }, opts?: VerifyReceiptOptions): Promise<boolean>;
127
+ verifyAnchor(anchorResponse: AnchorResponse): Promise<boolean>;
128
+
129
+ /** Recompute anchor hash from fields via SHA-256 chain (B-3, Node.js only). */
130
+ verifyAnchorHash(anchor: BeatAnchor): Promise<boolean>;
131
+
132
+ /** Verify on-chain tx status and optionally SPL Memo content (B-4). */
133
+ verifyOnChain(txSignature: string, opts?: VerifyOnChainOptions): Promise<VerifyOnChainResult>;
134
+
135
+ /** Set last known anchor for continuity tracking (B-2). Throws if chain is broken. */
136
+ setLastKnownAnchor(anchor: BeatAnchor): void;
137
+ /** Get last known anchor (B-2). Returns null if no anchor has been seen. */
138
+ getLastKnownAnchor(): BeatAnchor | null;
139
+ /** Re-establish chain continuity after a break. Requires explicit operator action. */
140
+ resync(anchor: BeatAnchor): void;
141
+ /** Returns true if chain continuity is broken and resync() is required. */
142
+ isBroken(): boolean;
143
+
144
+ /** Internal: resolve public key from cache or auto-fetch. */
145
+ _resolveKey(): Promise<string | null>;
146
+ }
147
+
148
+ export declare function createBeatsClient(options?: BeatsClientOptions): BeatsClient;
package/index.mjs CHANGED
@@ -1,178 +1,476 @@
1
- const DEFAULT_BASE_URL = 'https://beats.provenonce.dev';
2
- const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
3
- const BASE58_MAP = Object.fromEntries([...BASE58_ALPHABET].map((ch, i) => [ch, i]));
4
- const CLUSTER_RPC = {
5
- devnet: 'https://api.devnet.solana.com',
6
- testnet: 'https://api.testnet.solana.com',
7
- 'mainnet-beta': 'https://api.mainnet-beta.solana.com',
8
- };
9
-
10
- function toCanonicalJson(value) {
11
- const keys = Object.keys(value).sort();
12
- const out = {};
13
- for (const key of keys) out[key] = value[key];
14
- return JSON.stringify(out);
15
- }
16
-
17
- function decodeBase58(str) {
18
- if (!str || typeof str !== 'string') throw new Error('base58 value required');
19
- let bytes = [0];
20
- for (let i = 0; i < str.length; i++) {
21
- const val = BASE58_MAP[str[i]];
22
- if (val === undefined) throw new Error('invalid base58 character');
23
- let carry = val;
24
- for (let j = 0; j < bytes.length; j++) {
25
- const x = bytes[j] * 58 + carry;
26
- bytes[j] = x & 0xff;
27
- carry = x >> 8;
28
- }
29
- while (carry > 0) {
30
- bytes.push(carry & 0xff);
31
- carry >>= 8;
32
- }
33
- }
34
- for (let i = 0; i < str.length && str[i] === '1'; i++) bytes.push(0);
35
- return new Uint8Array(bytes.reverse());
36
- }
37
-
38
- function decodePublicKeyBytes(publicKey) {
39
- if (typeof publicKey !== 'string' || publicKey.length === 0) {
40
- throw new Error('receipt public key is required');
41
- }
42
- if (/^[0-9a-f]{64}$/i.test(publicKey)) {
43
- return Uint8Array.from(Buffer.from(publicKey, 'hex'));
44
- }
45
- return decodeBase58(publicKey);
46
- }
47
-
48
- async function verifyEd25519(payload, signatureBase64, publicKey) {
49
- const message = new TextEncoder().encode(toCanonicalJson(payload));
50
- const signature = Uint8Array.from(Buffer.from(signatureBase64, 'base64'));
51
- const keyBytes = decodePublicKeyBytes(publicKey);
52
-
53
- const subtle = globalThis.crypto?.subtle;
54
- if (subtle) {
55
- const key = await subtle.importKey('raw', keyBytes, { name: 'Ed25519' }, false, ['verify']);
56
- return subtle.verify({ name: 'Ed25519' }, key, signature, message);
57
- }
58
-
59
- // Node fallback for environments without WebCrypto subtle.
60
- const { createPublicKey, verify } = await import('node:crypto');
61
- const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
62
- const pubDer = Buffer.concat([spkiPrefix, Buffer.from(keyBytes)]);
63
- const key = createPublicKey({ key: pubDer, format: 'der', type: 'spki' });
64
- return verify(null, Buffer.from(message), key, Buffer.from(signature));
65
- }
66
-
67
- async function verifyOnChainTx({
68
- txSignature,
69
- rpcUrl,
70
- cluster = 'devnet',
71
- fetchImpl,
72
- }) {
73
- if (typeof txSignature !== 'string' || txSignature.length === 0) {
74
- throw new Error('txSignature is required');
75
- }
76
- const endpoint = rpcUrl || CLUSTER_RPC[cluster] || CLUSTER_RPC.devnet;
77
- const res = await fetchImpl(endpoint, {
78
- method: 'POST',
79
- headers: { 'content-type': 'application/json' },
80
- body: JSON.stringify({
81
- jsonrpc: '2.0',
82
- id: 1,
83
- method: 'getSignatureStatuses',
84
- params: [[txSignature], { searchTransactionHistory: true }],
85
- }),
86
- });
87
- const body = await res.json();
88
- if (!res.ok || body?.error) {
89
- throw new Error(`RPC verification failed for ${txSignature}`);
90
- }
91
- const status = body?.result?.value?.[0] || null;
92
- return {
93
- found: !!status,
94
- confirmationStatus: status?.confirmationStatus || null,
95
- finalized: status?.confirmationStatus === 'finalized',
96
- slot: status?.slot ?? null,
97
- };
98
- }
99
-
100
- export function createBeatsClient({ baseUrl = DEFAULT_BASE_URL, fetchImpl = globalThis.fetch } = {}) {
101
- if (typeof fetchImpl !== 'function') {
102
- throw new Error('fetch implementation is required');
103
- }
104
-
105
- const request = async (path, init = {}) => {
106
- const res = await fetchImpl(`${baseUrl}${path}`, init);
107
- const text = await res.text();
108
- let data = null;
109
- try {
110
- data = text ? JSON.parse(text) : null;
111
- } catch {
112
- data = { raw: text };
113
- }
114
- if (!res.ok) {
115
- const msg = data?.error || `HTTP ${res.status}`;
116
- const err = new Error(msg);
117
- err.status = res.status;
118
- err.data = data;
119
- throw err;
120
- }
121
- return data;
122
- };
123
-
124
- return {
125
- getHealth() {
126
- return request('/api/health');
127
- },
128
- async getAnchor(opts = {}) {
129
- const anchor = await request('/api/v1/beat/anchor');
130
- if (opts.verify === true) {
131
- const ok = await this.verifyAnchor(anchor);
132
- if (!ok) throw new Error('Anchor receipt verification failed');
133
- return { ...anchor, _verified_receipt: true };
134
- }
135
- return anchor;
136
- },
137
- getKey() {
138
- return request('/api/v1/beat/key');
139
- },
140
- verify(payload) {
141
- return request('/api/v1/beat/verify', {
142
- method: 'POST',
143
- headers: { 'content-type': 'application/json' },
144
- body: JSON.stringify(payload),
145
- });
146
- },
147
- timestampHash(hash) {
148
- return request('/api/v1/beat/timestamp', {
149
- method: 'POST',
150
- headers: { 'content-type': 'application/json' },
151
- body: JSON.stringify({ hash }),
152
- });
153
- },
154
- async verifyReceipt(response) {
155
- const payload = response?.timestamp || response?.anchor || response?.payload;
156
- const signature = response?.receipt?.signature || response?.signature;
157
- const publicKey = response?.receipt?.public_key || response?.public_key;
158
- if (!payload || !signature || !publicKey) return false;
159
- try {
160
- return await verifyEd25519(payload, signature, publicKey);
161
- } catch {
162
- return false;
163
- }
164
- },
165
- async verifyAnchor(anchorResponse) {
166
- if (!anchorResponse?.anchor || !anchorResponse?.receipt) return false;
167
- return this.verifyReceipt(anchorResponse);
168
- },
169
- async verifyOnChain(txSignature, opts = {}) {
170
- return verifyOnChainTx({
171
- txSignature,
172
- rpcUrl: opts.rpcUrl,
173
- cluster: opts.cluster || 'devnet',
174
- fetchImpl,
175
- });
176
- },
177
- };
178
- }
1
+ const DEFAULT_BASE_URL = 'https://beats.provenonce.dev';
2
+ const DEFAULT_TIMEOUT_MS = 30_000;
3
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
4
+ const BASE58_MAP = Object.fromEntries([...BASE58_ALPHABET].map((ch, i) => [ch, i]));
5
+ const CLUSTER_RPC = {
6
+ devnet: 'https://api.devnet.solana.com',
7
+ testnet: 'https://api.testnet.solana.com',
8
+ 'mainnet-beta': 'https://api.mainnet-beta.solana.com',
9
+ };
10
+ const HEX64 = /^[0-9a-f]{64}$/i;
11
+ const MEMO_PROGRAM_ID = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr';
12
+
13
+ // ============ UTILITIES ============
14
+
15
+ function toCanonicalJson(value) {
16
+ const keys = Object.keys(value).sort();
17
+ const out = {};
18
+ for (const key of keys) out[key] = value[key];
19
+ return JSON.stringify(out);
20
+ }
21
+
22
+ function decodeBase58(str) {
23
+ if (!str || typeof str !== 'string') throw new Error('base58 value required');
24
+ let bytes = [0];
25
+ for (let i = 0; i < str.length; i++) {
26
+ const val = BASE58_MAP[str[i]];
27
+ if (val === undefined) throw new Error('invalid base58 character');
28
+ let carry = val;
29
+ for (let j = 0; j < bytes.length; j++) {
30
+ const x = bytes[j] * 58 + carry;
31
+ bytes[j] = x & 0xff;
32
+ carry = x >> 8;
33
+ }
34
+ while (carry > 0) {
35
+ bytes.push(carry & 0xff);
36
+ carry >>= 8;
37
+ }
38
+ }
39
+ for (let i = 0; i < str.length && str[i] === '1'; i++) bytes.push(0);
40
+ return new Uint8Array(bytes.reverse());
41
+ }
42
+
43
+ function decodePublicKeyBytes(publicKey) {
44
+ if (typeof publicKey !== 'string' || publicKey.length === 0) {
45
+ throw new Error('receipt public key is required');
46
+ }
47
+ if (HEX64.test(publicKey)) {
48
+ return Uint8Array.from(Buffer.from(publicKey, 'hex'));
49
+ }
50
+ return decodeBase58(publicKey);
51
+ }
52
+
53
+ // ============ CRYPTOGRAPHIC VERIFICATION ============
54
+
55
+ async function verifyEd25519(payload, signatureBase64, publicKey) {
56
+ const message = new TextEncoder().encode(toCanonicalJson(payload));
57
+ const signature = Uint8Array.from(Buffer.from(signatureBase64, 'base64'));
58
+ const keyBytes = decodePublicKeyBytes(publicKey);
59
+
60
+ const subtle = globalThis.crypto?.subtle;
61
+ if (subtle) {
62
+ try {
63
+ const key = await subtle.importKey('raw', keyBytes, { name: 'Ed25519' }, false, ['verify']);
64
+ return subtle.verify({ name: 'Ed25519' }, key, signature, message);
65
+ } catch {
66
+ // Ed25519 not supported in this subtle implementation, fall through to Node
67
+ }
68
+ }
69
+
70
+ // Node.js fallback
71
+ const { createPublicKey, verify } = await import('node:crypto');
72
+ const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
73
+ const pubDer = Buffer.concat([spkiPrefix, Buffer.from(keyBytes)]);
74
+ const key = createPublicKey({ key: pubDer, format: 'der', type: 'spki' });
75
+ return verify(null, Buffer.from(message), key, Buffer.from(signature));
76
+ }
77
+
78
+ /**
79
+ * Recompute an anchor's hash from its fields (B-3).
80
+ * Requires Node.js crypto — not available in browsers.
81
+ * Returns true if the recomputed hash matches anchor.hash.
82
+ */
83
+ async function recomputeAnchorHash(anchor) {
84
+ if (!anchor || !HEX64.test(anchor.hash) || !HEX64.test(anchor.prev_hash)) return false;
85
+ if (!Number.isInteger(anchor.beat_index) || anchor.beat_index < 0) return false;
86
+ if (!Number.isInteger(anchor.difficulty) || anchor.difficulty <= 0) return false;
87
+ if (!Number.isInteger(anchor.utc) || anchor.utc < 0) return false;
88
+ if (!Number.isInteger(anchor.epoch) || anchor.epoch < 0) return false;
89
+
90
+ const { createHash } = await import('node:crypto');
91
+ const nonce = `anchor:${anchor.utc}:${anchor.epoch}`;
92
+ const seed = `${anchor.prev_hash}:${anchor.beat_index}:${nonce}`;
93
+ let current = createHash('sha256').update(seed, 'utf8').digest('hex');
94
+ for (let i = 0; i < anchor.difficulty; i++) {
95
+ current = createHash('sha256').update(current, 'utf8').digest('hex');
96
+ }
97
+ return current === anchor.hash;
98
+ }
99
+
100
+ // ============ ON-CHAIN VERIFICATION ============
101
+
102
+ async function verifyOnChainTx({
103
+ txSignature,
104
+ rpcUrl,
105
+ cluster = 'devnet',
106
+ fetchImpl,
107
+ }) {
108
+ if (typeof txSignature !== 'string' || txSignature.length === 0) {
109
+ throw new Error('txSignature is required');
110
+ }
111
+ const endpoint = rpcUrl || CLUSTER_RPC[cluster] || CLUSTER_RPC.devnet;
112
+ const res = await fetchImpl(endpoint, {
113
+ method: 'POST',
114
+ headers: { 'content-type': 'application/json' },
115
+ body: JSON.stringify({
116
+ jsonrpc: '2.0',
117
+ id: 1,
118
+ method: 'getSignatureStatuses',
119
+ params: [[txSignature], { searchTransactionHistory: true }],
120
+ }),
121
+ });
122
+ const body = await res.json();
123
+ if (!res.ok || body?.error) {
124
+ throw new Error(`RPC verification failed for ${txSignature}`);
125
+ }
126
+ const status = body?.result?.value?.[0] || null;
127
+ return {
128
+ found: !!status,
129
+ confirmationStatus: status?.confirmationStatus || null,
130
+ finalized: status?.confirmationStatus === 'finalized',
131
+ slot: status?.slot ?? null,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Fetch full transaction from Solana RPC and extract SPL Memo content (B-4).
137
+ * Returns parsed memo data or null if no memo found.
138
+ */
139
+ async function fetchTransactionMemo({ txSignature, rpcUrl, cluster = 'devnet', fetchImpl }) {
140
+ const endpoint = rpcUrl || CLUSTER_RPC[cluster] || CLUSTER_RPC.devnet;
141
+ const res = await fetchImpl(endpoint, {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/json' },
144
+ body: JSON.stringify({
145
+ jsonrpc: '2.0',
146
+ id: 2,
147
+ method: 'getTransaction',
148
+ params: [txSignature, { encoding: 'jsonParsed', commitment: 'finalized' }],
149
+ }),
150
+ });
151
+ const body = await res.json();
152
+ if (!res.ok || body?.error || !body?.result) return null;
153
+
154
+ const instructions = body.result.transaction?.message?.instructions || [];
155
+ for (const ix of instructions) {
156
+ // SPL Memo can appear as programId or program field
157
+ if (ix.programId === MEMO_PROGRAM_ID || ix.program === 'spl-memo') {
158
+ const raw = ix.parsed || ix.data;
159
+ if (!raw) continue;
160
+ // Memo may have a Solana program prefix: "[program] JSON"
161
+ let memoStr = typeof raw === 'string' ? raw : JSON.stringify(raw);
162
+ const bracketEnd = memoStr.indexOf('] ');
163
+ if (bracketEnd !== -1 && memoStr.startsWith('[')) {
164
+ memoStr = memoStr.slice(bracketEnd + 2);
165
+ }
166
+ try {
167
+ return JSON.parse(memoStr);
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // ============ CLIENT FACTORY ============
177
+
178
+ export function createBeatsClient({
179
+ baseUrl = DEFAULT_BASE_URL,
180
+ fetchImpl = globalThis.fetch,
181
+ pinnedPublicKey = null,
182
+ timeoutMs = DEFAULT_TIMEOUT_MS,
183
+ onStateChange = null,
184
+ loadState = null,
185
+ agentId = null,
186
+ } = {}) {
187
+ if (typeof fetchImpl !== 'function') {
188
+ throw new Error('fetch implementation is required');
189
+ }
190
+
191
+ // B-1: Key resolution state — pinned key has highest priority
192
+ let _cachedKey = pinnedPublicKey || null;
193
+
194
+ // B-2: Anchor continuity state
195
+ let _lastAnchor = null;
196
+ let _broken = false;
197
+ let _breakReason = null;
198
+
199
+ // Load persisted continuity state
200
+ if (typeof loadState === 'function') {
201
+ const saved = loadState();
202
+ if (saved && typeof saved.beat_index === 'number' && HEX64.test(saved.hash)) {
203
+ if (!agentId || saved.agent_id === agentId) {
204
+ _lastAnchor = { beat_index: saved.beat_index, hash: saved.hash };
205
+ }
206
+ }
207
+ }
208
+
209
+ function _persistState(anchor) {
210
+ if (typeof onStateChange === 'function' && anchor) {
211
+ onStateChange({ beat_index: anchor.beat_index, hash: anchor.hash, agent_id: agentId || null });
212
+ }
213
+ }
214
+
215
+ // Internal fetch with timeout (B-5)
216
+ const fetchWithTimeout = async (url, init = {}) => {
217
+ if (typeof AbortController !== 'undefined' && timeoutMs > 0) {
218
+ const controller = new AbortController();
219
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
220
+ try {
221
+ return await fetchImpl(url, { ...init, signal: controller.signal });
222
+ } finally {
223
+ clearTimeout(timeoutId);
224
+ }
225
+ }
226
+ return fetchImpl(url, init);
227
+ };
228
+
229
+ const request = async (path, init = {}) => {
230
+ const res = await fetchWithTimeout(`${baseUrl}${path}`, init);
231
+ const text = await res.text();
232
+ let data = null;
233
+ try {
234
+ data = text ? JSON.parse(text) : null;
235
+ } catch {
236
+ data = { raw: text };
237
+ }
238
+ if (!res.ok) {
239
+ const msg = data?.error || `HTTP ${res.status}`;
240
+ const err = new Error(msg);
241
+ err.status = res.status;
242
+ err.data = data;
243
+ throw err;
244
+ }
245
+ return data;
246
+ };
247
+
248
+ return {
249
+ // ---- Health ----
250
+ getHealth() {
251
+ return request('/api/health');
252
+ },
253
+
254
+ // ---- Anchor (with continuity tracking B-2) ----
255
+ async getAnchor(opts = {}) {
256
+ // Fail closed if chain is broken
257
+ if (_broken) {
258
+ const err = new Error(`Chain continuity broken: ${_breakReason}. Call resync() to re-establish.`);
259
+ err.code = 'CHAIN_BROKEN';
260
+ throw err;
261
+ }
262
+
263
+ const data = await request('/api/v1/beat/anchor');
264
+ const anchor = data?.anchor;
265
+
266
+ // B-2: Strict continuity validation against last known anchor
267
+ if (anchor && _lastAnchor) {
268
+ if (anchor.beat_index < _lastAnchor.beat_index) {
269
+ _broken = true;
270
+ _breakReason = `regression from ${_lastAnchor.beat_index} to ${anchor.beat_index}`;
271
+ const err = new Error(
272
+ `Anchor chain regression: server returned beat_index ${anchor.beat_index}, ` +
273
+ `but last known was ${_lastAnchor.beat_index}`
274
+ );
275
+ err.code = 'ANCHOR_REGRESSION';
276
+ throw err;
277
+ }
278
+ // Same beat_index: must be same hash (idempotent re-fetch)
279
+ if (anchor.beat_index === _lastAnchor.beat_index) {
280
+ if (anchor.hash !== _lastAnchor.hash) {
281
+ _broken = true;
282
+ _breakReason = `fork at beat_index ${anchor.beat_index}: hash changed`;
283
+ const err = new Error(
284
+ `Anchor chain fork at beat_index ${anchor.beat_index}: ` +
285
+ `hash ${anchor.hash} differs from last known ${_lastAnchor.hash}`
286
+ );
287
+ err.code = 'ANCHOR_FORK';
288
+ throw err;
289
+ }
290
+ } else if (anchor.beat_index === _lastAnchor.beat_index + 1) {
291
+ // Consecutive: prev_hash must link
292
+ if (anchor.prev_hash !== _lastAnchor.hash) {
293
+ _broken = true;
294
+ _breakReason = `prev_hash mismatch at beat_index ${anchor.beat_index}`;
295
+ const err = new Error(
296
+ `Anchor chain break at beat_index ${anchor.beat_index}: ` +
297
+ `prev_hash ${anchor.prev_hash} does not match last known hash ${_lastAnchor.hash}`
298
+ );
299
+ err.code = 'ANCHOR_CHAIN_BREAK';
300
+ throw err;
301
+ }
302
+ } else {
303
+ // Non-consecutive jump: fail closed
304
+ _broken = true;
305
+ _breakReason = `beat_index jumped from ${_lastAnchor.beat_index} to ${anchor.beat_index}`;
306
+ const err = new Error(
307
+ `Anchor chain jump: beat_index ${anchor.beat_index} is not consecutive ` +
308
+ `(expected ${_lastAnchor.beat_index + 1})`
309
+ );
310
+ err.code = 'ANCHOR_JUMP';
311
+ throw err;
312
+ }
313
+ }
314
+
315
+ // B-1: Receipt verification with pinned/cached key (never response key)
316
+ if (opts.verify === true) {
317
+ const ok = await this.verifyAnchor(data);
318
+ if (!ok) throw new Error('Anchor receipt verification failed');
319
+ data._verified_receipt = true;
320
+ }
321
+
322
+ // B-3: Optional hash recomputation
323
+ if (opts.recompute === true && anchor) {
324
+ const hashValid = await recomputeAnchorHash(anchor);
325
+ if (!hashValid) throw new Error('Anchor hash recomputation failed — server returned invalid hash');
326
+ data._verified_hash = true;
327
+ }
328
+
329
+ // Update continuity state + persist
330
+ if (anchor && (!_lastAnchor || anchor.beat_index > _lastAnchor.beat_index)) {
331
+ _lastAnchor = { ...anchor };
332
+ _persistState(_lastAnchor);
333
+ }
334
+
335
+ return data;
336
+ },
337
+
338
+ // ---- Key ----
339
+ getKey() {
340
+ return request('/api/v1/beat/key');
341
+ },
342
+
343
+ // ---- Verify (server-side) ----
344
+ verify(payload) {
345
+ return request('/api/v1/beat/verify', {
346
+ method: 'POST',
347
+ headers: { 'content-type': 'application/json' },
348
+ body: JSON.stringify(payload),
349
+ });
350
+ },
351
+
352
+ // ---- Timestamp ----
353
+ timestampHash(hash) {
354
+ return request('/api/v1/beat/timestamp', {
355
+ method: 'POST',
356
+ headers: { 'content-type': 'application/json' },
357
+ body: JSON.stringify({ hash }),
358
+ });
359
+ },
360
+
361
+ // ---- Receipt Verification (B-1: key pinning) ----
362
+ async verifyReceipt(response, opts = {}) {
363
+ const payload = response?.timestamp || response?.anchor || response?.payload;
364
+ const signature = response?.receipt?.signature || response?.signature;
365
+ if (!payload || !signature) return false;
366
+
367
+ // B-1: Key resolution priority — param > pinned > cached > auto-fetch
368
+ // NEVER use response.receipt.public_key for verification
369
+ const key = opts.publicKey || _cachedKey || await this._resolveKey();
370
+ if (!key) return false;
371
+
372
+ try {
373
+ return await verifyEd25519(payload, signature, key);
374
+ } catch {
375
+ return false;
376
+ }
377
+ },
378
+
379
+ async verifyAnchor(anchorResponse) {
380
+ if (!anchorResponse?.anchor || !anchorResponse?.receipt) return false;
381
+ return this.verifyReceipt(anchorResponse);
382
+ },
383
+
384
+ // ---- Anchor Hash Recomputation (B-3) ----
385
+ verifyAnchorHash(anchor) {
386
+ return recomputeAnchorHash(anchor);
387
+ },
388
+
389
+ // ---- On-Chain Verification (B-4: memo content check) ----
390
+ async verifyOnChain(txSignature, opts = {}) {
391
+ const rpcUrl = opts.rpcUrl || CLUSTER_RPC[opts.cluster || 'devnet'];
392
+ const status = await verifyOnChainTx({
393
+ txSignature,
394
+ rpcUrl,
395
+ cluster: opts.cluster || 'devnet',
396
+ fetchImpl: fetchWithTimeout,
397
+ });
398
+
399
+ if (!status.found || !status.finalized) return status;
400
+
401
+ // B-4: If expectedPayload provided, fetch tx and verify memo content
402
+ if (opts.expectedPayload) {
403
+ const memo = await fetchTransactionMemo({
404
+ txSignature,
405
+ rpcUrl,
406
+ cluster: opts.cluster || 'devnet',
407
+ fetchImpl: fetchWithTimeout,
408
+ });
409
+
410
+ if (!memo) {
411
+ return { ...status, memoVerified: false, reason: 'No SPL Memo instruction found in transaction' };
412
+ }
413
+
414
+ const expected = opts.expectedPayload;
415
+ // Compare critical anchor fields
416
+ const memoMatch = (
417
+ (expected.hash === undefined || memo.hash === expected.hash) &&
418
+ (expected.beat_index === undefined || memo.beat_index === expected.beat_index) &&
419
+ (expected.prev_hash === undefined || memo.prev === expected.prev_hash) &&
420
+ (expected.utc === undefined || memo.utc === expected.utc) &&
421
+ (expected.difficulty === undefined || memo.difficulty === expected.difficulty) &&
422
+ (expected.epoch === undefined || memo.epoch === expected.epoch) &&
423
+ // Timestamp memo fields
424
+ (expected.anchor_index === undefined || memo.anchor_index === expected.anchor_index) &&
425
+ (expected.anchor_hash === undefined || memo.anchor_hash === expected.anchor_hash)
426
+ );
427
+
428
+ return { ...status, memoVerified: memoMatch, memoData: memo };
429
+ }
430
+
431
+ return status;
432
+ },
433
+
434
+ // ---- Continuity State (B-2) ----
435
+ setLastKnownAnchor(anchor) {
436
+ if (_broken) {
437
+ throw new Error('Chain is broken. Call resync() to re-establish continuity.');
438
+ }
439
+ if (anchor && typeof anchor.beat_index === 'number' && HEX64.test(anchor.hash)) {
440
+ _lastAnchor = { ...anchor };
441
+ _persistState(_lastAnchor);
442
+ }
443
+ },
444
+
445
+ getLastKnownAnchor() {
446
+ return _lastAnchor ? { ..._lastAnchor } : null;
447
+ },
448
+
449
+ resync(anchor) {
450
+ if (!anchor || typeof anchor.beat_index !== 'number' || !HEX64.test(anchor.hash)) {
451
+ throw new Error('resync requires a valid anchor with beat_index and hash');
452
+ }
453
+ _broken = false;
454
+ _breakReason = null;
455
+ _lastAnchor = { ...anchor };
456
+ _persistState(_lastAnchor);
457
+ },
458
+
459
+ isBroken() {
460
+ return _broken;
461
+ },
462
+
463
+ // ---- Internal: Key Resolution (B-1) ----
464
+ async _resolveKey() {
465
+ if (_cachedKey) return _cachedKey;
466
+ try {
467
+ const keyData = await this.getKey();
468
+ // Prefer hex (more portable) over base58
469
+ _cachedKey = keyData.public_key_hex || keyData.public_key_base58;
470
+ return _cachedKey;
471
+ } catch {
472
+ return null;
473
+ }
474
+ },
475
+ };
476
+ }
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
- {
2
- "name": "@provenonce/beats-client",
3
- "version": "0.2.0",
4
- "description": "Minimal client for the public Provenonce Beats service",
5
- "type": "module",
6
- "main": "index.mjs",
7
- "types": "index.d.ts",
8
- "exports": {
9
- ".": {
10
- "import": "./index.mjs",
11
- "types": "./index.d.ts"
12
- }
13
- },
14
- "files": [
15
- "index.mjs",
16
- "index.d.ts",
17
- "README.md"
18
- ],
19
- "license": "MIT"
20
- }
1
+ {
2
+ "name": "@provenonce/beats-client",
3
+ "version": "0.3.0",
4
+ "description": "Minimal client for the public Provenonce Beats service",
5
+ "type": "module",
6
+ "main": "index.mjs",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./index.mjs",
11
+ "types": "./index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.mjs",
16
+ "index.d.ts",
17
+ "README.md"
18
+ ],
19
+ "license": "MIT"
20
+ }