-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathattestationAction.ts
More file actions
470 lines (426 loc) · 15 KB
/
attestationAction.ts
File metadata and controls
470 lines (426 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
/**
* Attestation action implementation
*
* This module provides methods for requesting, retrieving, and listing attestations.
* Attestations are cryptographically signed proofs of query results that can be
* consumed by smart contracts and external applications.
*/
import { Types, Utils } from '@trufnetwork/kwil-js';
import { Action } from './action';
import {
RequestAttestationInput,
RequestAttestationResult,
GetSignedAttestationInput,
SignedAttestationResult,
ListAttestationsInput,
AttestationMetadata,
validateAttestationRequest,
validateListAttestationsInput,
} from '../types/attestation';
import { encodeActionArgs } from '../util/AttestationEncoding';
/**
* AttestationAction provides methods for working with data attestations
*
* Attestations enable validators to cryptographically sign query results,
* providing verifiable proofs that can be used in smart contracts.
*
* @example
* ```typescript
* const client = new NodeTNClient({ ... });
* const attestationAction = client.loadAttestationAction();
*
* // Request an attestation
* const result = await attestationAction.requestAttestation({
* dataProvider: "0x4710a8d8f0d845da110086812a32de6d90d7ff5c",
* streamId: "stai0000000000000000000000000000",
* actionName: "get_record",
* args: [dataProvider, streamId, fromTime, toTime, null, false],
* encryptSig: false,
* maxFee: 1000000,
* });
*
* // Wait for signing (1-2 blocks)
* await new Promise(resolve => setTimeout(resolve, 10000));
*
* // Retrieve signed attestation
* const signed = await attestationAction.getSignedAttestation({
* requestTxId: result.requestTxId,
* });
* ```
*/
export class AttestationAction extends Action {
/**
* Request a signed attestation of query results
*
* This submits a transaction requesting that validators execute a query
* and sign the results. The leader validator will sign the attestation
* asynchronously (typically 1-2 blocks later).
*
* @param input - Attestation request parameters
* @returns Promise resolving to request result with transaction ID
* @throws Error if validation fails or transaction fails
*
* @example
* ```typescript
* const result = await attestationAction.requestAttestation({
* dataProvider: "0x4710a8d8f0d845da110086812a32de6d90d7ff5c",
* streamId: "stai0000000000000000000000000000",
* actionName: "get_record",
* args: [
* "0x4710a8d8f0d845da110086812a32de6d90d7ff5c",
* "stai0000000000000000000000000000",
* Math.floor(Date.now() / 1000) - 86400, // 1 day ago
* Math.floor(Date.now() / 1000),
* null,
* false,
* ],
* encryptSig: false,
* maxFee: 1000000,
* });
* console.log(`Request TX ID: ${result.requestTxId}`);
* ```
*/
async requestAttestation(
input: RequestAttestationInput
): Promise<RequestAttestationResult> {
// Validate input
validateAttestationRequest(input);
// Encode arguments
const argsBytes = encodeActionArgs(input.args);
// Prepare named parameters for request_attestation action
// Note: maxFee must be a string with NUMERIC type to encode as NUMERIC(788,0) for wei amounts
const params: Types.NamedParams[] = [{
$data_provider: input.dataProvider,
$stream_id: input.streamId,
$action_name: input.actionName,
$args_bytes: argsBytes,
$encrypt_sig: input.encryptSig,
$max_fee: input.maxFee.toString(),
}];
// Specify types - maxFee needs NUMERIC(78,0) for large wei amounts
const types = {
$max_fee: Utils.DataType.Numeric(78, 0),
};
// Execute request_attestation action
const result = await this.executeWithNamedParams('request_attestation', params, types);
// Check for errors
if (!result.data?.tx_hash) {
throw new Error(
'Failed to request attestation: no transaction hash returned'
);
}
// Return the 64-char hex tx_hash
return {
requestTxId: result.data.tx_hash,
};
}
/**
* Retrieve a complete signed attestation payload
*
* This fetches the signed attestation payload for a given request transaction ID.
* The attestation must have been signed by the leader validator first.
*
* If the attestation is not yet signed, this will throw an error. Clients should
* poll with exponential backoff or wait for 1-2 blocks after requesting.
*
* @param input - Request transaction ID
* @returns Promise resolving to signed attestation payload
* @throws Error if attestation not found or not yet signed
*
* @example
* ```typescript
* // Poll for signature
* for (let i = 0; i < 15; i++) {
* try {
* const signed = await attestationAction.getSignedAttestation({
* requestTxId: "0x123...",
* });
* console.log(`Payload: ${Buffer.from(signed.payload).toString('hex')}`);
* break;
* } catch (e) {
* await new Promise(resolve => setTimeout(resolve, 2000));
* }
* }
* ```
*/
async getSignedAttestation(
input: GetSignedAttestationInput
): Promise<SignedAttestationResult> {
// Validate and normalize input
const trimmed = input.requestTxId?.trim() || '';
if (trimmed === '') {
throw new Error('request_tx_id is required');
}
// Strip optional "0x" prefix and lowercase for normalization
const normalizedRequestTxId = trimmed.startsWith('0x')
? trimmed.slice(2).toLowerCase()
: trimmed.toLowerCase();
// Call get_signed_attestation view action
const result = await this.call<Array<{ payload: string | Uint8Array }>>(
'get_signed_attestation',
{ $request_tx_id: normalizedRequestTxId }
);
// Extract the right value from Either, or throw if Left
const data = result.throw();
// The action returns an array of rows - extract the first row
if (!data || !Array.isArray(data) || data.length === 0) {
throw new Error('No attestation found for request_tx_id - may not exist or is not yet signed');
}
const row = data[0];
if (!row || !row.payload) {
throw new Error('No payload in attestation row');
}
// Decode base64 to bytes
let payloadBytes: Uint8Array;
const payloadValue = row.payload;
if (typeof payloadValue === 'string') {
// Node.js environment
if (typeof Buffer !== 'undefined') {
payloadBytes = new Uint8Array(Buffer.from(payloadValue, 'base64'));
} else {
// Browser environment
const binaryString = atob(payloadValue);
payloadBytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
payloadBytes[i] = binaryString.charCodeAt(i);
}
}
} else if (payloadValue instanceof Uint8Array) {
// Already decoded
payloadBytes = payloadValue;
} else {
throw new Error(`Unexpected payload type: ${typeof payloadValue}`);
}
return {
payload: payloadBytes,
};
}
/**
* List attestation metadata with optional filtering
*
* This returns metadata for attestations, optionally filtered by requester address, request transaction ID,
* attestation hash, or result canonical bytes. Supports pagination and sorting.
*
* @param input - Filter and pagination parameters
* @returns Promise resolving to array of attestation metadata
* @throws Error if parameters are invalid
*
* @example
* ```typescript
* // List my recent attestations
* const myAddress = new Uint8Array(Buffer.from(wallet.address.slice(2), 'hex'));
* const attestations = await attestationAction.listAttestations({
* requester: myAddress,
* limit: 10,
* offset: 0,
* orderBy: "created_height desc",
* });
*
* attestations.forEach(att => {
* console.log(`TX: ${att.requestTxId}, Height: ${att.createdHeight}`);
* });
* ```
*/
async listAttestations(
input: ListAttestationsInput
): Promise<AttestationMetadata[]> {
// Validate input
validateListAttestationsInput(input);
// Set defaults
const limit = input.limit ?? 5000;
const offset = input.offset ?? 0;
// Prepare parameters for list_attestations view action
// Note: Empty Uint8Array represents BYTEA NULL (handled by kwil-js 0.9.10+)
const params: Types.NamedParams = {
$requester: input.requester ?? new Uint8Array(0),
$request_tx_id: input.requestTxId ?? null,
$attestation_hash: input.attestationHash ?? new Uint8Array(0),
$result_canonical: input.resultCanonical ?? new Uint8Array(0),
$limit: limit,
$offset: offset,
$order_by: input.orderBy ?? null,
};
// Call list_attestations view action
const result = await this.call<any[]>('list_attestations', params);
// Check for errors
if (result.isLeft()) {
throw new Error(
`Failed to list attestations: HTTP status ${result.value}`
);
}
// Extract the right value from Either
// Note: result.value might be a getter function, so call it if needed
const rightValue = typeof result.value === 'function' ? result.value() : result.value;
const rows = Array.isArray(rightValue) ? rightValue : [];
// If no rows, return empty array
if (!rows || rows.length === 0) {
return [];
}
// Parse rows into AttestationMetadata
return rows.map((row: any, idx: number) => parseAttestationRow(row, idx));
}
}
/**
* Parse a single row from list_attestations result into AttestationMetadata
*
* Expected columns (in order):
* 0. request_tx_id (TEXT)
* 1. attestation_hash (BYTEA)
* 2. requester (BYTEA)
* 3. data_provider (TEXT)
* 4. stream_id (TEXT)
* 5. created_height (INT8)
* 6. signed_height (INT8, nullable)
* 7. encrypt_sig (BOOLEAN)
*/
function parseAttestationRow(row: any, idx: number): AttestationMetadata {
// kwil-js returns rows as objects with column names as keys
// or as arrays depending on the query format
let requestTxId: string;
let attestationHash: Uint8Array;
let requester: Uint8Array;
let dataProvider: string;
let streamId: string;
let createdHeight: number;
let signedHeight: number | null;
let encryptSig: boolean;
// Handle both array and object formats
if (Array.isArray(row)) {
// Array format: [col0, col1, col2, ...]
if (row.length < 8) {
throw new Error(
`Row ${idx}: expected 8 columns, got ${row.length}`
);
}
requestTxId = row[0];
attestationHash = decodeBytea(row[1], idx, 'attestation_hash');
requester = decodeBytea(row[2], idx, 'requester');
dataProvider = row[3];
streamId = row[4];
createdHeight = parseInt(row[5], 10);
signedHeight = row[6] !== null ? parseInt(row[6], 10) : null;
encryptSig = row[7];
} else {
// Object format: { request_tx_id: ..., attestation_hash: ..., ... }
requestTxId = row.request_tx_id;
attestationHash = decodeBytea(row.attestation_hash, idx, 'attestation_hash');
requester = decodeBytea(row.requester, idx, 'requester');
dataProvider = row.data_provider;
streamId = row.stream_id;
createdHeight = parseInt(row.created_height, 10);
signedHeight = row.signed_height !== null ? parseInt(row.signed_height, 10) : null;
encryptSig = row.encrypt_sig;
}
return {
requestTxId,
attestationHash,
requester,
dataProvider,
streamId,
createdHeight,
signedHeight,
encryptSig,
};
}
/**
* Decode a BYTEA column from base64
*/
function decodeBytea(value: any, rowIdx: number, colName: string): Uint8Array {
if (value === null || value === undefined) {
throw new Error(`Row ${rowIdx}: ${colName} is null or undefined`);
}
// If already Uint8Array, return as-is
if (value instanceof Uint8Array) {
return value;
}
// If string, decode from base64
if (typeof value === 'string') {
try {
// Node.js environment
if (typeof Buffer !== 'undefined') {
return new Uint8Array(Buffer.from(value, 'base64'));
} else {
// Browser environment
const binaryString = atob(value);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
} catch (err) {
throw new Error(
`Row ${rowIdx}: failed to decode ${colName} as base64: ${err}`
);
}
}
throw new Error(
`Row ${rowIdx}: expected ${colName} to be string or Uint8Array, got ${typeof value}`
);
}
// Inline unit tests
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest;
describe('decodeBytea', () => {
it('should decode base64 string', () => {
const base64 = Buffer.from([1, 2, 3, 4]).toString('base64');
const decoded = decodeBytea(base64, 0, 'test');
expect(Array.from(decoded)).toEqual([1, 2, 3, 4]);
});
it('should return Uint8Array as-is', () => {
const bytes = new Uint8Array([1, 2, 3, 4]);
const decoded = decodeBytea(bytes, 0, 'test');
expect(decoded).toBe(bytes);
});
it('should throw on null', () => {
expect(() => decodeBytea(null, 0, 'test')).toThrow('null or undefined');
});
it('should throw on invalid type', () => {
expect(() => decodeBytea(123, 0, 'test')).toThrow('expected test to be string or Uint8Array');
});
});
describe('parseAttestationRow', () => {
it('should parse array format row', () => {
const row = [
'tx123',
Buffer.from([1, 2, 3]).toString('base64'),
Buffer.from([4, 5, 6]).toString('base64'),
'0x4710a8d8f0d845da110086812a32de6d90d7ff5c',
'stai0000000000000000000000000000',
'100',
'200',
true,
];
const metadata = parseAttestationRow(row, 0);
expect(metadata.requestTxId).toBe('tx123');
expect(Array.from(metadata.attestationHash)).toEqual([1, 2, 3]);
expect(Array.from(metadata.requester)).toEqual([4, 5, 6]);
expect(metadata.dataProvider).toBe('0x4710a8d8f0d845da110086812a32de6d90d7ff5c');
expect(metadata.streamId).toBe('stai0000000000000000000000000000');
expect(metadata.createdHeight).toBe(100);
expect(metadata.signedHeight).toBe(200);
expect(metadata.encryptSig).toBe(true);
});
it('should parse object format row', () => {
const row = {
request_tx_id: 'tx456',
attestation_hash: Buffer.from([7, 8, 9]).toString('base64'),
requester: Buffer.from([10, 11, 12]).toString('base64'),
data_provider: '0x1234567890123456789012345678901234567890',
stream_id: 'stbx0000000000000000000000000000',
created_height: '300',
signed_height: null,
encrypt_sig: false,
};
const metadata = parseAttestationRow(row, 0);
expect(metadata.requestTxId).toBe('tx456');
expect(Array.from(metadata.attestationHash)).toEqual([7, 8, 9]);
expect(Array.from(metadata.requester)).toEqual([10, 11, 12]);
expect(metadata.dataProvider).toBe('0x1234567890123456789012345678901234567890');
expect(metadata.streamId).toBe('stbx0000000000000000000000000000');
expect(metadata.createdHeight).toBe(300);
expect(metadata.signedHeight).toBe(null);
expect(metadata.encryptSig).toBe(false);
});
});
}