diff --git a/README.md b/README.md index 8fd3976..6edaf67 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,39 @@ attestations.forEach(att => { }); ``` +#### Parsing Attestation Payloads + +The SDK provides utilities to parse and verify signed attestation payloads: + +```typescript +import { parseAttestationPayload } from "@trufnetwork/sdk-js"; +import { sha256, recoverAddress } from "ethers"; + +// Get signed attestation +const signed = await attestationAction.getSignedAttestation({ + requestTxId: result.requestTxId, +}); + +// Extract canonical payload and signature +const canonicalPayload = signed.payload.slice(0, -65); +const signature = signed.payload.slice(-65); + +// Verify signature +const digest = sha256(canonicalPayload); +const validatorAddress = recoverAddress(digest, { + r: "0x" + Buffer.from(signature.slice(0, 32)).toString("hex"), + s: "0x" + Buffer.from(signature.slice(32, 64)).toString("hex"), + v: signature[64] +}); + +// Parse and decode the payload +const parsed = parseAttestationPayload(canonicalPayload); +console.log(`Validator: ${validatorAddress}`); +console.log(`Query Results: ${parsed.result.length} rows`); +``` + +**📖 For complete documentation including signature verification, payload structure, result decoding, and EVM integration examples, see the [Attestation Payload Parsing](./docs/api-reference.md#attestation-payload-parsing) section in the API Reference.** + #### Attestation Payload Structure The signed attestation payload is a binary blob containing: @@ -284,7 +317,7 @@ The signed attestation payload is a binary blob containing: 5. Stream ID (32 bytes, length-prefixed) 6. Action ID (2 bytes) 7. Arguments (variable, length-prefixed) -8. Result (variable, length-prefixed) +8. Result (variable, ABI-encoded, length-prefixed) 9. Signature (65 bytes, secp256k1) This payload can be passed to EVM smart contracts for on-chain verification using `ecrecover`. @@ -450,6 +483,7 @@ For other bundlers or serverless platforms, consult their documentation on modul | Get stream taxonomy | `composedAction.getTaxonomiesForStreams({streams, latestOnly})` | | Request attestation | `attestationAction.requestAttestation({dataProvider, streamId, actionName, args, encryptSig, maxFee})` | | Get signed attestation | `attestationAction.getSignedAttestation({requestTxId})` | +| Parse attestation payload | `parseAttestationPayload(canonicalPayload)` | | List attestations | `attestationAction.listAttestations({requester, limit, offset, orderBy})` | | Get transaction event | `transactionAction.getTransactionEvent({txId})` | | List transaction fees | `transactionAction.listTransactionFees({wallet, mode, limit, offset})` | diff --git a/docs/api-reference.md b/docs/api-reference.md index 5909f2f..3d47843 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -747,6 +747,167 @@ await primitiveAction.insertRecord({ - ⚡ **High-throughput data insertion** (independent records) - ⚡ **Fire-and-forget operations** (with proper error handling) +## Attestation Payload Parsing + +### `parseAttestationPayload(payload: Uint8Array): ParsedAttestationPayload` +Parses and decodes a canonical attestation payload (without the 65-byte signature). + +#### Parameters +- `payload: Uint8Array` - Canonical payload bytes (full payload minus last 65 bytes) + +#### Returns +- `ParsedAttestationPayload` object with: + - `version: number` - Protocol version (currently 1) + - `algorithm: number` - Signature algorithm (0 = secp256k1) + - `blockHeight: bigint` - Block height when attestation was created + - `dataProvider: string` - Data provider Ethereum address (hex format) + - `streamId: string` - Stream identifier + - `actionId: number` - Action identifier + - `arguments: any[]` - Decoded action arguments + - `result: DecodedRow[]` - Decoded query results as rows (see [`DecodedRow`](#decodedrow)) + +#### Example +```typescript +import { parseAttestationPayload } from "@trufnetwork/sdk-js"; +import { sha256, recoverAddress } from "ethers"; + +// Get signed attestation +const attestationAction = client.loadAttestationAction(); +const signedAttestation = await attestationAction.getSignedAttestation({ + requestTxId: "0x..." +}); + +// Extract canonical payload (without signature) +const payloadBytes = signedAttestation.payload; +const canonicalPayload = payloadBytes.slice(0, -65); +const signature = payloadBytes.slice(-65); + +// Verify signature +const digest = sha256(canonicalPayload); +const r = "0x" + Buffer.from(signature.slice(0, 32)).toString("hex"); +const s = "0x" + Buffer.from(signature.slice(32, 64)).toString("hex"); +const v = signature[64]; +const validatorAddress = recoverAddress(digest, { r, s, v }); + +// Parse payload +const parsed = parseAttestationPayload(canonicalPayload); + +console.log(`Validator: ${validatorAddress}`); +console.log(`Block: ${parsed.blockHeight}`); +console.log(`Provider: ${parsed.dataProvider}`); +console.log(`Stream: ${parsed.streamId}`); +console.log(`Results: ${parsed.result.length} rows`); + +// Access query results +parsed.result.forEach((row, idx) => { + const [timestamp, value] = row.values; + console.log(`Row ${idx + 1}: timestamp=${timestamp}, value=${value}`); +}); +``` + +### `DecodedRow` + +Represents a decoded row from attestation query results. + +#### Type Definition +```typescript +interface DecodedRow { + values: any[]; +} +``` + +#### Fields +- `values: any[]` - Array of decoded column values + - For attestation results: `values[0]` is the timestamp (string), `values[1]` is the value (string) + - Values are decoded according to their data types (integers as BigInt, strings as string, etc.) + +#### Example +```typescript +// Example DecodedRow from attestation result +const row: DecodedRow = { + values: [ + "1704067200", // timestamp (Unix time as string) + "77.051806494788211665" // value (18-decimal fixed-point as string) + ] +}; + +// Accessing row data +const [timestamp, value] = row.values; +console.log(`Timestamp: ${timestamp}, Value: ${value}`); +``` + +**Note**: When used in attestation results (via `parseAttestationPayload`), each `DecodedRow` contains exactly two values: a Unix timestamp and a decimal value string. + +### Attestation Result Format + +Query results in attestations are ABI-encoded as: +```solidity +abi.encode(uint256[] timestamps, int256[] values) +``` + +Where: +- **timestamps**: Array of Unix timestamps (uint256) +- **values**: Array of 18-decimal fixed-point integers (int256) + +Example decoded output: +```javascript +[ + { values: ["1704067200", "77.051806494788211665"] }, + { values: ["1704153600", "78.718654581755352351"] }, + // ... +] +``` + +### Complete Attestation Workflow + +```typescript +// 1. Request attestation +const attestationAction = client.loadAttestationAction(); +const result = await attestationAction.requestAttestation({ + dataProvider: "0x4710a8d8f0d845da110086812a32de6d90d7ff5c", + streamId: "stai0000000000000000000000000000", + actionName: "get_record", + args: [...], + encryptSig: false, + maxFee: 1000000, +}); + +// 2. Wait for transaction confirmation +await client.waitForTx(result.requestTxId); + +// 3. Poll for signature (validators sign asynchronously) +let signedAttestation; +for (let i = 0; i < 15; i++) { + try { + signedAttestation = await attestationAction.getSignedAttestation({ + requestTxId: result.requestTxId, + }); + if (signedAttestation.payload.length > 65) break; + } catch (e) { + await new Promise(resolve => setTimeout(resolve, 2000)); + } +} + +// 4. Parse and verify +const canonicalPayload = signedAttestation.payload.slice(0, -65); +const signature = signedAttestation.payload.slice(-65); + +const digest = sha256(canonicalPayload); +const validatorAddress = recoverAddress(digest, { + r: "0x" + Buffer.from(signature.slice(0, 32)).toString("hex"), + s: "0x" + Buffer.from(signature.slice(32, 64)).toString("hex"), + v: signature[64], +}); + +const parsed = parseAttestationPayload(canonicalPayload); + +// 5. Use the verified data +console.log(`✅ Verified by: ${validatorAddress}`); +parsed.result.forEach(row => { + console.log(`Data: ${row.values}`); +}); +``` + ## Performance Recommendations - Use batch record insertions - Implement client-side caching diff --git a/examples/attestation/index.ts b/examples/attestation/index.ts index 1d3982f..5098882 100644 --- a/examples/attestation/index.ts +++ b/examples/attestation/index.ts @@ -14,6 +14,7 @@ import { NodeTNClient } from "../../src"; import { Wallet, sha256, recoverAddress } from "ethers"; +import { parseAttestationPayload } from "../../src/util/AttestationEncoding"; async function main() { // ===== 1. Setup Client ===== @@ -48,13 +49,14 @@ async function main() { const dataProvider = "0x4710a8d8f0d845da110086812a32de6d90d7ff5c"; // AI Index data provider const streamId = "stai0000000000000000000000000000"; // AI Index stream - // Query last 7 days of data - const now = Math.floor(Date.now() / 1000); - const weekAgo = now - 7 * 24 * 60 * 60; + // Query data from January 2024 (when the stream has data) + const startTime = 1704067200; // Jan 1, 2024 00:00:00 UTC + const endTime = startTime + (30 * 24 * 60 * 60); // 30 days later console.log(`Data Provider: ${dataProvider}`); console.log(`Stream ID: ${streamId}`); - console.log(`Time Range: ${new Date(weekAgo * 1000).toISOString()} to ${new Date(now * 1000).toISOString()}\n`); + console.log(`Time Range: ${new Date(startTime * 1000).toISOString()} to ${new Date(endTime * 1000).toISOString()}`); + console.log(` (Using historical data from January 2024)\n`); // ===== 3. List My Recent Attestations ===== console.log("===== Listing Recent Attestations =====\n"); @@ -129,7 +131,30 @@ async function main() { console.log(`✅ Validator Address: ${validatorAddress}`); console.log(` This is the address you should use in your EVM smart contract's verify() function\n`); - console.log(` 💡 How to use this payload:`); + // ===== Parse Attestation Payload ===== + console.log(`===== Parsing Attestation Payload =====`); + + const parsed = parseAttestationPayload(canonicalPayload); + + console.log(`📋 Attestation Details:`); + console.log(` Version: ${parsed.version}`); + console.log(` Algorithm: ${parsed.algorithm} (0 = secp256k1)`); + console.log(` Block Height: ${parsed.blockHeight}`); + console.log(` Data Provider: ${parsed.dataProvider}`); + console.log(` Stream ID: ${parsed.streamId}`); + console.log(` Action ID: ${parsed.actionId}\n`); + + console.log(`📊 Attested Query Result (from get_record):`); + if (parsed.result.length === 0) { + console.log(` No records found`); + } else { + console.log(` Found ${parsed.result.length} row(s):\n`); + parsed.result.forEach((row, idx) => { + console.log(` Row ${idx + 1}: ${JSON.stringify(row.values)}`); + }); + } + + console.log(`\n 💡 How to use this payload:`); console.log(` 1. Send this hex payload to your EVM smart contract`); console.log(` 2. The contract can verify the signature using ecrecover`); console.log(` 3. Parse the payload to extract the attested query results`); @@ -154,8 +179,8 @@ async function main() { args: [ dataProvider, streamId, - weekAgo, - now, + startTime, + endTime, null, // frozen_at (not used) false, // use_cache (will be forced to false for determinism) ], @@ -215,6 +240,48 @@ async function main() { console.log(`First 64 bytes (hex): ${Buffer.from(signedAttestation.payload.slice(0, 64)).toString("hex")}`); console.log(`Last 65 bytes (signature): ${Buffer.from(signedAttestation.payload.slice(-65)).toString("hex")}`); console.log(`Full payload (hex): ${Buffer.from(signedAttestation.payload).toString("hex")}\n`); + + // ===== Parse and Display the Attestation ===== + try { + console.log(`===== Parsing Attestation Payload =====`); + + const signatureOffset = signedAttestation.payload.length - 65; + const canonicalPayload = signedAttestation.payload.slice(0, signatureOffset); + const signature = signedAttestation.payload.slice(signatureOffset); + + // Verify signature + const digest = sha256(canonicalPayload); + const r = "0x" + Buffer.from(signature.slice(0, 32)).toString("hex"); + const s = "0x" + Buffer.from(signature.slice(32, 64)).toString("hex"); + const v = signature[64]; + const validatorAddress = recoverAddress(digest, { r, s, v }); + + console.log(`✅ Validator Address: ${validatorAddress}\n`); + + // Parse payload + const parsed = parseAttestationPayload(canonicalPayload); + + console.log(`📋 Attestation Details:`); + console.log(` Version: ${parsed.version}`); + console.log(` Algorithm: ${parsed.algorithm} (0 = secp256k1)`); + console.log(` Block Height: ${parsed.blockHeight}`); + console.log(` Data Provider: ${parsed.dataProvider}`); + console.log(` Stream ID: ${parsed.streamId}`); + console.log(` Action ID: ${parsed.actionId}\n`); + + console.log(`📊 Attested Query Result (from get_record):`); + if (parsed.result.length === 0) { + console.log(` No records found`); + } else { + console.log(` Found ${parsed.result.length} row(s):\n`); + parsed.result.forEach((row, idx) => { + console.log(` Row ${idx + 1}: ${JSON.stringify(row.values)}`); + }); + } + console.log(""); + } catch (parseErr: any) { + console.log(`⚠️ Could not parse payload: ${parseErr.message}\n`); + } } } catch (err: any) { // Check if it's an insufficient balance error diff --git a/src/internal.ts b/src/internal.ts index bd6a50c..d1be6ec 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -21,6 +21,16 @@ export { StreamId } from "./util/StreamId"; export { EthereumAddress } from "./util/EthereumAddress"; export { visibility } from "./util/visibility"; +// Attestation encoding/decoding utilities +export { + parseAttestationPayload +} from "./util/AttestationEncoding"; + +export type { + DecodedRow, + ParsedAttestationPayload +} from "./util/AttestationEncoding"; + // Contract values and types export { StreamType } from "./contracts-api/contractValues"; diff --git a/src/util/AttestationEncoding.ts b/src/util/AttestationEncoding.ts index 838df68..b5d9969 100644 --- a/src/util/AttestationEncoding.ts +++ b/src/util/AttestationEncoding.ts @@ -7,6 +7,7 @@ */ import { Utils, Types } from '@trufnetwork/kwil-js'; +import { AbiCoder } from 'ethers'; /** * Encodes action arguments into canonical bytes using kwil-js utilities. @@ -80,7 +81,7 @@ function writeUint32LE(buffer: Uint8Array, value: number, offset: number): void } /** - * Reads a uint32 value in little-endian format (for testing) + * Reads a uint32 value in little-endian format * * @param buffer - Source buffer * @param offset - Offset in buffer @@ -95,6 +96,566 @@ export function readUint32LE(buffer: Uint8Array, offset: number): number { ) >>> 0; // Convert to unsigned 32-bit integer } +/** + * Reads a uint16 value in little-endian format + * + * @param buffer - Source buffer + * @param offset - Offset in buffer + * @returns The uint16 value + */ +export function readUint16LE(buffer: Uint8Array, offset: number): number { + return (buffer[offset] | (buffer[offset + 1] << 8)) >>> 0; +} + +/** + * Reads a uint32 value in big-endian format + * + * @param buffer - Source buffer + * @param offset - Offset in buffer + * @returns The uint32 value + */ +export function readUint32BE(buffer: Uint8Array, offset: number): number { + return ( + (buffer[offset] << 24) | + (buffer[offset + 1] << 16) | + (buffer[offset + 2] << 8) | + buffer[offset + 3] + ) >>> 0; +} + +/** + * Reads a uint16 value in big-endian format + * + * @param buffer - Source buffer + * @param offset - Offset in buffer + * @returns The uint16 value + */ +export function readUint16BE(buffer: Uint8Array, offset: number): number { + return ((buffer[offset] << 8) | buffer[offset + 1]) >>> 0; +} + +/** + * Decoded data type information + */ +export interface DecodedDataType { + name: string; + is_array: boolean; + metadata: number[]; +} + +/** + * Decoded EncodedValue structure + */ +export interface DecodedEncodedValue { + type: DecodedDataType; + data: Uint8Array[]; +} + +/** + * Decoded query result row + */ +export interface DecodedRow { + values: any[]; +} + +/** + * Parsed attestation payload structure + */ +export interface ParsedAttestationPayload { + version: number; + algorithm: number; + blockHeight: bigint; + dataProvider: string; + streamId: string; + actionId: number; + arguments: any[]; + result: DecodedRow[]; +} + +/** + * Decodes DataType from bytes (reverse of encodeDataType) + * + * @param buffer - Source buffer + * @param offset - Starting offset + * @returns Decoded data type and new offset + */ +function decodeDataType(buffer: Uint8Array, offset: number): { type: DecodedDataType; offset: number } { + // Version (uint16 BE) + const version = readUint16BE(buffer, offset); + offset += 2; + + if (version !== 0) { + throw new Error(`Unsupported DataType version: ${version}`); + } + + // Name length (uint32 BE) + const nameLen = readUint32BE(buffer, offset); + offset += 4; + + // Name bytes + const nameBytes = buffer.slice(offset, offset + nameLen); + const name = new TextDecoder().decode(nameBytes); + offset += nameLen; + + // is_array (1 byte boolean) + const is_array = buffer[offset] === 1; + offset += 1; + + // Metadata (2 x uint16 BE) + const metadata0 = readUint16BE(buffer, offset); + offset += 2; + const metadata1 = readUint16BE(buffer, offset); + offset += 2; + + return { + type: { + name, + is_array, + metadata: [metadata0, metadata1], + }, + offset, + }; +} + +/** + * Decodes an EncodedValue from bytes (reverse of encodeEncodedValue) + * + * @param buffer - Source buffer containing the encoded value + * @param offset - Starting offset (default 0) + * @returns Decoded value and new offset + */ +export function decodeEncodedValue( + buffer: Uint8Array, + offset: number = 0 +): { value: DecodedEncodedValue; offset: number } { + // Version (uint16 LE) + const version = readUint16LE(buffer, offset); + offset += 2; + + if (version !== 0) { + throw new Error(`Unsupported EncodedValue version: ${version}`); + } + + // Type length (uint32 LE) + const typeLen = readUint32LE(buffer, offset); + offset += 4; + + // Type bytes + const typeBytes = buffer.slice(offset, offset + typeLen); + const { type } = decodeDataType(typeBytes, 0); + offset += typeLen; + + // Data array length (uint16 LE) + const dataLen = readUint16LE(buffer, offset); + offset += 2; + + // Data items + const data: Uint8Array[] = []; + for (let i = 0; i < dataLen; i++) { + // Data item length (uint32 LE) + const itemLen = readUint32LE(buffer, offset); + offset += 4; + + // Data item bytes + const itemBytes = buffer.slice(offset, offset + itemLen); + data.push(itemBytes); + offset += itemLen; + } + + return { + value: { type, data }, + offset, + }; +} + +/** + * Converts a decoded EncodedValue to a JavaScript value + * + * @param decoded - Decoded EncodedValue + * @returns JavaScript value (string, number, boolean, null, Uint8Array, or array) + */ +export function decodedValueToJS(decoded: DecodedEncodedValue): any { + // Handle NULL values (data array is empty or first item indicates null) + if (decoded.data.length === 0) { + return null; + } + + // Check the null indicator (first byte of first data item) + const firstItem = decoded.data[0]; + if (firstItem.length === 0 || firstItem[0] === 0) { + return null; + } + + // Extract actual value bytes (skip null indicator byte) + const valueBytes = firstItem.slice(1); + + // Decode based on type name + const typeName = decoded.type.name.toLowerCase(); + + if (decoded.type.is_array) { + // Handle array types + const result: any[] = []; + for (const item of decoded.data) { + if (item.length === 0 || item[0] === 0) { + result.push(null); + } else { + const itemBytes = item.slice(1); + result.push(decodeSingleValue(typeName, itemBytes)); + } + } + return result; + } + + // Handle scalar types + return decodeSingleValue(typeName, valueBytes); +} + +/** + * Decodes a single value based on type name + */ +function decodeSingleValue(typeName: string, bytes: Uint8Array): any { + switch (typeName) { + case 'text': + case 'uuid': + return new TextDecoder().decode(bytes); + + case 'int': + case 'int8': + case 'integer': + // Decode as 8-byte signed int64 (big-endian as per kwil-db) + if (bytes.length === 8) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + // Use getBigInt64 to properly decode signed int64 + return view.getBigInt64(0, false); // false = big-endian + } + throw new Error(`Invalid integer byte length: expected 8, got ${bytes.length}`); + + case 'bool': + case 'boolean': + return bytes.length > 0 && bytes[0] === 1; + + case 'numeric': + case 'decimal': + return new TextDecoder().decode(bytes); + + case 'bytea': + case 'blob': + return bytes; + + default: + // Unknown type, return as string if possible + try { + return new TextDecoder().decode(bytes); + } catch { + return bytes; + } + } +} + +/** + * Decodes canonical query result bytes into rows and columns + * + * Format: + * [row_count: uint32 LE] + * [col_count: uint32 LE] + * [col_len: uint32 LE][col_bytes: EncodedValue.MarshalBinary()] + * ... + * [col_count: uint32 LE] + * ... + * + * @param data - Canonical query result bytes + * @returns Array of decoded rows + */ +export function decodeCanonicalQueryResult(data: Uint8Array): DecodedRow[] { + let offset = 0; + + // Row count (uint32 LE) + if (data.length < 4) { + throw new Error('Data too short for row count'); + } + + const rowCount = readUint32LE(data, offset); + offset += 4; + + const rows: DecodedRow[] = []; + + for (let i = 0; i < rowCount; i++) { + // Column count (uint32 LE) + if (offset + 4 > data.length) { + throw new Error(`Data too short for column count at row ${i}`); + } + + const colCount = readUint32LE(data, offset); + offset += 4; + + const values: any[] = []; + + for (let j = 0; j < colCount; j++) { + // Column length (uint32 LE) + if (offset + 4 > data.length) { + throw new Error(`Data too short for column ${j} length at row ${i}`); + } + + const colLen = readUint32LE(data, offset); + offset += 4; + + // Column bytes + if (offset + colLen > data.length) { + throw new Error(`Data too short for column ${j} bytes at row ${i}`); + } + + const colBytes = data.slice(offset, offset + colLen); + + // Decode the EncodedValue + const { value: decodedValue } = decodeEncodedValue(colBytes, 0); + + // Convert to JavaScript value + const jsValue = decodedValueToJS(decodedValue); + values.push(jsValue); + + offset += colLen; + } + + rows.push({ values }); + } + + return rows; +} + +/** + * Decodes ABI-encoded datapoints result (timestamps and values) + * + * Format: abi.encode(uint256[] timestamps, int256[] values) + * + * @param data - ABI-encoded bytes + * @returns Array of decoded rows with [timestamp, value] pairs + */ +export function decodeABIDatapoints(data: Uint8Array): DecodedRow[] { + // Handle empty data + if (!data || data.length === 0) { + return []; + } + + const abiCoder = AbiCoder.defaultAbiCoder(); + + try { + // Decode as (uint256[], int256[]) + const decoded = abiCoder.decode( + ['uint256[]', 'int256[]'], + data + ); + + const timestamps = decoded[0] as bigint[]; + const values = decoded[1] as bigint[]; + + if (timestamps.length !== values.length) { + throw new Error(`Timestamp/value array length mismatch: ${timestamps.length} vs ${values.length}`); + } + + const rows: DecodedRow[] = []; + for (let i = 0; i < timestamps.length; i++) { + rows.push({ + values: [ + timestamps[i].toString(), + // Convert from 18-decimal fixed point to decimal string + formatFixedPoint(values[i], 18) + ] + }); + } + + return rows; + } catch (err) { + throw new Error(`Failed to decode ABI datapoints: ${err}`); + } +} + +/** + * Formats a fixed-point integer value to decimal string + * + * @param value - BigInt value with fixed decimals + * @param decimals - Number of decimal places + * @returns Formatted decimal string + */ +function formatFixedPoint(value: bigint, decimals: number): string { + const isNegative = value < 0n; + const absValue = isNegative ? -value : value; + + const divisor = 10n ** BigInt(decimals); + const integerPart = absValue / divisor; + const fractionalPart = absValue % divisor; + + // Pad fractional part with leading zeros + const fractionalStr = fractionalPart.toString().padStart(decimals, '0'); + + // Remove trailing zeros from fractional part + const trimmedFractional = fractionalStr.replace(/0+$/, ''); + + if (trimmedFractional === '') { + return `${isNegative ? '-' : ''}${integerPart}`; + } + + return `${isNegative ? '-' : ''}${integerPart}.${trimmedFractional}`; +} + +/** + * Parses a canonical attestation payload (without signature) + * + * Payload format: + * 1. Version (1 byte) + * 2. Algorithm (1 byte, 0 = secp256k1) + * 3. Block height (8 bytes, uint64 big-endian) + * 4. Data provider (length-prefixed with 4 bytes big-endian) + * 5. Stream ID (length-prefixed with 4 bytes big-endian) + * 6. Action ID (2 bytes, uint16 big-endian) + * 7. Arguments (length-prefixed with 4 bytes big-endian) + * 8. Result (length-prefixed with 4 bytes big-endian) + * + * @param payload - Canonical payload bytes (without 65-byte signature) + * @returns Parsed payload structure + */ +export function parseAttestationPayload(payload: Uint8Array): ParsedAttestationPayload { + let offset = 0; + + // 1. Version (1 byte) + if (payload.length < 1) { + throw new Error('Payload too short for version'); + } + const version = payload[offset]; + offset += 1; + + // 2. Algorithm (1 byte) + if (offset >= payload.length) { + throw new Error('Payload too short for algorithm'); + } + const algorithm = payload[offset]; + offset += 1; + + // 3. Block height (8 bytes, uint64 big-endian) + if (offset + 8 > payload.length) { + throw new Error('Payload too short for block height'); + } + const blockHeightHigh = readUint32BE(payload, offset); + const blockHeightLow = readUint32BE(payload, offset + 4); + const blockHeight = (BigInt(blockHeightHigh) << 32n) | BigInt(blockHeightLow); + offset += 8; + + // 4. Data provider (length-prefixed, 4 bytes big-endian) + if (offset + 4 > payload.length) { + throw new Error('Payload too short for data provider length'); + } + const dataProviderLen = readUint32BE(payload, offset); + offset += 4; + + if (offset + dataProviderLen > payload.length) { + throw new Error('Payload too short for data provider'); + } + const dataProviderBytes = payload.slice(offset, offset + dataProviderLen); + // Data provider is typically a hex address (20 bytes for Ethereum address) + // Try to decode as UTF-8 first, if it looks like a hex string keep it + // Otherwise convert bytes to hex + let dataProvider: string; + if (dataProviderLen === 20) { + // Likely an Ethereum address (20 bytes) + dataProvider = '0x' + Array.from(dataProviderBytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } else { + // Try UTF-8 decoding + try { + const decoded = new TextDecoder().decode(dataProviderBytes); + // Check if it looks like a hex address string (starts with "0x") + if (decoded.startsWith('0x') && /^0x[0-9a-fA-F]+$/.test(decoded)) { + dataProvider = decoded; + } else { + // Assume it's a valid UTF-8 string + dataProvider = decoded; + } + } catch { + // Fallback to hex + dataProvider = '0x' + Array.from(dataProviderBytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + } + offset += dataProviderLen; + + // 5. Stream ID (length-prefixed, 4 bytes big-endian) + if (offset + 4 > payload.length) { + throw new Error('Payload too short for stream ID length'); + } + const streamIdLen = readUint32BE(payload, offset); + offset += 4; + + if (offset + streamIdLen > payload.length) { + throw new Error('Payload too short for stream ID'); + } + const streamIdBytes = payload.slice(offset, offset + streamIdLen); + const streamId = new TextDecoder().decode(streamIdBytes); + offset += streamIdLen; + + // 6. Action ID (2 bytes, uint16 big-endian) + if (offset + 2 > payload.length) { + throw new Error('Payload too short for action ID'); + } + const actionId = readUint16BE(payload, offset); + offset += 2; + + // 7. Arguments (length-prefixed, 4 bytes big-endian) + if (offset + 4 > payload.length) { + throw new Error('Payload too short for arguments length'); + } + const argsLen = readUint32BE(payload, offset); + offset += 4; + + if (offset + argsLen > payload.length) { + throw new Error('Payload too short for arguments'); + } + const argsBytes = payload.slice(offset, offset + argsLen); + offset += argsLen; + + // Decode arguments + let args: any[] = []; + if (argsLen > 0) { + let argsOffset = 0; + + // Arguments format: [arg_count: uint32 LE][length: uint32 LE][encoded_arg]... + const argCount = readUint32LE(argsBytes, argsOffset); + argsOffset += 4; + + for (let i = 0; i < argCount; i++) { + const argLen = readUint32LE(argsBytes, argsOffset); + argsOffset += 4; + + const argBytes = argsBytes.slice(argsOffset, argsOffset + argLen); + const { value: decodedArg } = decodeEncodedValue(argBytes, 0); + args.push(decodedValueToJS(decodedArg)); + argsOffset += argLen; + } + } + + // 8. Result (length-prefixed, 4 bytes big-endian) + if (offset + 4 > payload.length) { + throw new Error('Payload too short for result length'); + } + const resultLen = readUint32BE(payload, offset); + offset += 4; + + if (offset + resultLen > payload.length) { + throw new Error('Payload too short for result'); + } + const resultBytes = payload.slice(offset, offset + resultLen); + + // Decode result (ABI-encoded as uint256[], int256[]) + const result = decodeABIDatapoints(resultBytes); + + return { + version, + algorithm, + blockHeight, + dataProvider, + streamId, + actionId, + arguments: args, + result, + }; +} + // Inline unit tests if (import.meta.vitest) { const { describe, it, expect } = import.meta.vitest; @@ -207,4 +768,47 @@ if (import.meta.vitest) { expect(buffer[3]).toBe(0x12); }); }); + + describe('readUint16LE and readUint16BE', () => { + it('should read uint16 little-endian correctly', () => { + const buffer = new Uint8Array([0x78, 0x56]); + expect(readUint16LE(buffer, 0)).toBe(0x5678); + }); + + it('should read uint16 big-endian correctly', () => { + const buffer = new Uint8Array([0x56, 0x78]); + expect(readUint16BE(buffer, 0)).toBe(0x5678); + }); + }); + + describe('readUint32BE', () => { + it('should read uint32 big-endian correctly', () => { + const buffer = new Uint8Array([0x12, 0x34, 0x56, 0x78]); + expect(readUint32BE(buffer, 0)).toBe(0x12345678); + }); + }); + + describe('decodeCanonicalQueryResult', () => { + it('should decode empty result (0 rows)', () => { + const buffer = new Uint8Array(4); + writeUint32LE(buffer, 0, 0); // row count = 0 + + const result = decodeCanonicalQueryResult(buffer); + expect(result.length).toBe(0); + }); + + it('should throw on invalid data', () => { + const buffer = new Uint8Array(2); // Too short + expect(() => decodeCanonicalQueryResult(buffer)).toThrow('Data too short for row count'); + }); + }); + + describe('parseAttestationPayload', () => { + it.todo('should parse payload with ABI-encoded result (TODO: need to construct synthetic test data with valid ABI encoding - see examples/attestation/index.ts for working integration test)'); + + it('should throw on invalid version', () => { + const payload = new Uint8Array(1); + expect(() => parseAttestationPayload(payload)).toThrow(); + }); + }); }