@provenonce/beats-client 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/README.md +50 -0
- package/index.d.ts +96 -0
- package/index.mjs +178 -0
- package/package.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +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
|
+
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
|
package/index.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +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
|
+
}
|