diff --git a/README.md b/README.md index ba94993..eb7f4a1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Description -This directory contains development tools to test and simulate Chainlink Functions. It also contains an example of a contract to test the functions. +This directory contains development tools to test and simulate Chainlink Functions, an example consumer, and the Solidity attestation verification library used by TrufNetwork contracts. ## Requirements @@ -78,3 +78,7 @@ See [TNOracle Documentation](contracts/v1.0.0/TNOracle.md). - Support Refer to the [Developer Guide](docs/DeveloperGuide.md). + +#### Attestation Library + +Smart contracts that ingest signed attestations can import `contracts/attestation/TrufAttestation.sol` to parse payloads, recover signer addresses, and decode datapoints. Start with the [Attestation Library guide](docs/AttestationLibrary.md) for payload format, usage snippets, and TypeScript helpers that mirror the canonical encoder maintained in `github.com/trufnetwork/node` (`extensions/tn_attestation/canonical.go`). diff --git a/contracts/attestation/TrufAttestation.sol b/contracts/attestation/TrufAttestation.sol new file mode 100644 index 0000000..034658e --- /dev/null +++ b/contracts/attestation/TrufAttestation.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.27; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/// @title TrufAttestation +/// @notice Parses and validates attestation payloads produced by the TrufNetwork node. +/// @dev Payloads follow the canonical encoding generated in the node repository. View the +/// Go migration `024-attestation-actions.sql` for the authoritative format. +library TrufAttestation { + /// @dev Length in bytes of the appended ECDSA signature. + uint256 private constant SIGNATURE_LENGTH = 65; + /// @dev Currently supported canonical payload version. + uint8 public constant VERSION_V1 = 1; + /// @dev Only secp256k1 signatures are supported in v1 payloads. + uint8 public constant ALGORITHM_SECP256K1 = 0; + + /// @dev Enumeration of query actions currently allow-listed for attestations. + enum Action { + NONE, + GET_RECORD, + GET_INDEX, + GET_CHANGE_OVER_TIME, + GET_LAST_RECORD, + GET_FIRST_RECORD + } + + /// @dev Convenience constants that mirror the `Action` enum. + uint8 public constant ACTION_GET_RECORD = uint8(Action.GET_RECORD); + uint8 public constant ACTION_GET_INDEX = uint8(Action.GET_INDEX); + uint8 public constant ACTION_GET_CHANGE_OVER_TIME = uint8(Action.GET_CHANGE_OVER_TIME); + uint8 public constant ACTION_GET_LAST_RECORD = uint8(Action.GET_LAST_RECORD); + uint8 public constant ACTION_GET_FIRST_RECORD = uint8(Action.GET_FIRST_RECORD); + + /// @notice Emitted when the payload structure does not match the canonical encoding. + error AttestationInvalidLength(); + /// @notice Emitted when the signature appended to the payload is not 65 bytes. + error AttestationInvalidSignatureLength(); + /// @notice Emitted when the attestation algorithm byte is not recognised. + error AttestationInvalidAlgorithm(uint8 algorithm); + /// @notice Emitted when an attestation version is not supported by this library. + error AttestationUnsupportedVersion(uint8 version); + /// @notice Emitted when the canonical data provider is not a 20-byte address. + error AttestationUnexpectedDataProviderLength(uint256 length); + /// @notice Emitted when the canonical stream identifier is not 32 bytes. + error AttestationUnexpectedStreamLength(uint256 length); + /// @notice Emitted when an action identifier falls outside the enum bounds. + error AttestationUnexpectedActionId(uint16 actionId); + /// @notice Emitted when decoded datapoint arrays have mismatched lengths. + error AttestationArrayLengthMismatch(); + + /// @notice Structured representation of an attestation payload (canonical bytes + signature). + /// @dev The `actionId` maps to the `Action` enum; callers can use {toAction} for a safe cast. + struct Attestation { + uint8 version; + uint8 algorithm; + uint64 blockHeight; + address dataProvider; + bytes32 streamId; + uint8 actionId; + bytes args; + bytes result; + bytes signature; + } + + /// @notice Canonical output datapoints decoded from `att.result`. + struct DataPoint { + uint256 timestamp; + int256 value; + } + + /// @notice Parse a raw attestation payload into its structured representation. + /// @param payload Signed attestation bytes (`canonical || signature`). + /// @return att Structured attestation. + function parse(bytes calldata payload) internal pure returns (Attestation memory att) { + if (payload.length <= SIGNATURE_LENGTH) revert AttestationInvalidLength(); + + uint256 canonicalLength = payload.length - SIGNATURE_LENGTH; + bytes memory canonical = new bytes(canonicalLength); + bytes memory signature = new bytes(SIGNATURE_LENGTH); + assembly { + calldatacopy(add(canonical, 0x20), payload.offset, canonicalLength) + calldatacopy(add(signature, 0x20), add(payload.offset, canonicalLength), SIGNATURE_LENGTH) + } + + att.signature = signature; + + uint256 offset; + att.version = uint8(canonical[offset]); + offset += 1; + if (att.version != VERSION_V1) revert AttestationUnsupportedVersion(att.version); + + att.algorithm = uint8(canonical[offset]); + offset += 1; + if (att.algorithm != ALGORITHM_SECP256K1) revert AttestationInvalidAlgorithm(att.algorithm); + + att.blockHeight = _readUint64(canonical, offset); + offset += 8; + + (bytes memory providerBytes, uint256 nextOffset) = _readLengthPrefixed(canonical, offset); + offset = nextOffset; + if (providerBytes.length != 20) revert AttestationUnexpectedDataProviderLength(providerBytes.length); + att.dataProvider = _bytesToAddress(providerBytes); + + bytes memory streamBytes; + (streamBytes, offset) = _readLengthPrefixed(canonical, offset); + if (streamBytes.length != 32) revert AttestationUnexpectedStreamLength(streamBytes.length); + att.streamId = _bytesToBytes32(streamBytes); + + uint16 rawAction = _readUint16(canonical, offset); + offset += 2; + att.actionId = _normalizeActionId(rawAction); + + (att.args, offset) = _readLengthPrefixed(canonical, offset); + (att.result, offset) = _readLengthPrefixed(canonical, offset); + + if (offset != canonical.length) revert AttestationInvalidLength(); + + return att; + } + + /// @notice Verify that an attestation was signed by the expected validator. + /// @param att Structured attestation. + /// @param expectedValidator Address the caller trusts as the signer. + /// @return True if the signature matches `expectedValidator`. + function verify(Attestation memory att, address expectedValidator) internal pure returns (bool) { + if (att.signature.length != SIGNATURE_LENGTH) revert AttestationInvalidSignatureLength(); + if (att.algorithm != ALGORITHM_SECP256K1) revert AttestationInvalidAlgorithm(att.algorithm); + + bytes32 digest = hash(att); + address recovered = ECDSA.recover(digest, att.signature); + return recovered == expectedValidator; + } + + /// @notice Parse and verify a raw payload. + function verify(bytes calldata payload, address expectedValidator) internal pure returns (bool) { + return verify(parse(payload), expectedValidator); + } + + /// @notice Compute the canonical hash for an attestation. + function hash(Attestation memory att) internal pure returns (bytes32) { + bytes memory canonical = _encodeCanonical(att); + return sha256(canonical); + } + + /// @notice Parse and hash a raw payload. + function hash(bytes calldata payload) internal pure returns (bytes32) { + return hash(parse(payload)); + } + + /// @notice Decode datapoints from the canonical result bytes. + function decodeDataPoints(Attestation memory att) internal pure returns (DataPoint[] memory) { + (uint256[] memory timestamps, int256[] memory values) = abi.decode(att.result, (uint256[], int256[])); + if (timestamps.length != values.length) revert AttestationArrayLengthMismatch(); + + DataPoint[] memory points = new DataPoint[](timestamps.length); + for (uint256 i = 0; i < timestamps.length; ++i) { + points[i] = DataPoint({timestamp: timestamps[i], value: values[i]}); + } + return points; + } + + /// @notice Parse and decode datapoints from a raw payload. + function decodeDataPoints(bytes calldata payload) internal pure returns (DataPoint[] memory) { + return decodeDataPoints(parse(payload)); + } + + /// @notice Extract commonly used metadata fields from an attestation. + /// @return blockHeight Block height the attestation was produced at. + /// @return dataProvider 20-byte provider address. + /// @return streamId 32-byte stream identifier. + /// @return actionId Raw action identifier. + function metadata(Attestation memory att) + internal + pure + returns (uint64, address, bytes32, uint8) + { + return (att.blockHeight, att.dataProvider, att.streamId, att.actionId); + } + + /// @notice Parse and extract metadata from a raw payload. + function metadata(bytes calldata payload) + internal + pure + returns (uint64, address, bytes32, uint8) + { + return metadata(parse(payload)); + } + + /// @notice Return the encoded args/result blobs from an attestation. + function body(Attestation memory att) internal pure returns (bytes memory, bytes memory) { + return (att.args, att.result); + } + + /// @notice Parse and return the encoded args/result blobs from a raw payload. + function body(bytes calldata payload) internal pure returns (bytes memory, bytes memory) { + return body(parse(payload)); + } + + /// @notice Convert an action identifier to the enum representation. + /// @dev Reverts if `actionId` is out of range. + function toAction(uint8 actionId) internal pure returns (Action) { + return Action(_requireKnownActionId(actionId)); + } + + /// @notice Convert the action identifier from a structured attestation. + function toAction(Attestation memory att) internal pure returns (Action) { + return toAction(att.actionId); + } + + function _encodeCanonical(Attestation memory att) private pure returns (bytes memory) { + return abi.encodePacked( + bytes1(att.version), + bytes1(att.algorithm), + bytes8(att.blockHeight), + _lengthPrefix(abi.encodePacked(att.dataProvider)), + _lengthPrefix(abi.encodePacked(att.streamId)), + bytes2(uint16(att.actionId)), + _lengthPrefix(att.args), + _lengthPrefix(att.result) + ); + } + + function _lengthPrefix(bytes memory data) private pure returns (bytes memory) { + return abi.encodePacked(bytes4(uint32(data.length)), data); + } + + function _readLengthPrefixed(bytes memory data, uint256 offset) private pure returns (bytes memory chunk, uint256 next) { + if (data.length < offset + 4) revert AttestationInvalidLength(); + uint256 length; + assembly { + length := shr(224, mload(add(add(data, 0x20), offset))) + } + offset += 4; + + if (data.length < offset + length) revert AttestationInvalidLength(); + chunk = new bytes(length); + for (uint256 i; i < length; ) { + chunk[i] = data[offset + i]; + unchecked { + ++i; + } + } + next = offset + length; + } + + function _readUint16(bytes memory data, uint256 offset) private pure returns (uint16 result) { + if (data.length < offset + 2) revert AttestationInvalidLength(); + assembly { + result := shr(240, mload(add(add(data, 0x20), offset))) + } + } + + function _readUint64(bytes memory data, uint256 offset) private pure returns (uint64 result) { + if (data.length < offset + 8) revert AttestationInvalidLength(); + assembly { + result := shr(192, mload(add(add(data, 0x20), offset))) + } + } + + function _bytesToAddress(bytes memory data) private pure returns (address addr) { + if (data.length != 20) revert AttestationUnexpectedDataProviderLength(data.length); + assembly { + addr := shr(96, mload(add(data, 0x20))) + } + } + + function _bytesToBytes32(bytes memory data) private pure returns (bytes32 result) { + if (data.length != 32) revert AttestationUnexpectedStreamLength(data.length); + assembly { + result := mload(add(data, 0x20)) + } + } + + function _normalizeActionId(uint16 raw) private pure returns (uint8) { + if (raw == 0 || raw > type(uint8).max) revert AttestationUnexpectedActionId(raw); + return uint8(raw); + } + + function _requireKnownActionId(uint8 actionId) private pure returns (uint8) { + if (actionId == 0 || actionId > uint8(type(Action).max)) revert AttestationUnexpectedActionId(uint16(actionId)); + return actionId; + } +} diff --git a/contracts/attestation/TrufAttestationHarness.sol b/contracts/attestation/TrufAttestationHarness.sol new file mode 100644 index 0000000..db16e67 --- /dev/null +++ b/contracts/attestation/TrufAttestationHarness.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.27; + +import {TrufAttestation} from "./TrufAttestation.sol"; + +contract TrufAttestationHarness { + using TrufAttestation for bytes; + using TrufAttestation for TrufAttestation.Attestation; + + function parse(bytes calldata payload) external pure returns (TrufAttestation.Attestation memory) { + return TrufAttestation.parse(payload); + } + + function hash(bytes calldata payload) external pure returns (bytes32) { + return TrufAttestation.hash(payload); + } + + function verify(bytes calldata payload, address expectedValidator) external pure returns (bool) { + return TrufAttestation.verify(payload, expectedValidator); + } + + function decodeDataPoints(bytes calldata payload) + external + pure + returns (TrufAttestation.DataPoint[] memory) + { + return TrufAttestation.decodeDataPoints(payload); + } + + function metadata(bytes calldata payload) + external + pure + returns (uint64, address, bytes32, uint8) + { + return TrufAttestation.metadata(payload); + } + + function body(bytes calldata payload) external pure returns (bytes memory, bytes memory) { + return TrufAttestation.body(payload); + } + + function toAction(uint8 actionId) external pure returns (TrufAttestation.Action) { + return TrufAttestation.toAction(actionId); + } +} diff --git a/docs/AttestationLibrary.md b/docs/AttestationLibrary.md new file mode 100644 index 0000000..8acea23 --- /dev/null +++ b/docs/AttestationLibrary.md @@ -0,0 +1,62 @@ +# Attestation Library + +The `contracts/attestation/TrufAttestation.sol` library lets Solidity contracts verify TrufNetwork attestations on-chain. Phase 1 keeps the scope small: single secp256k1 signer, canonical payload parsing, and caller-managed safeguards. + +## When to Use It +- You receive the nine-field payload returned by `get_signed_attestation`. +- You need to parse canonical bytes, recover the validator address, and decode `(timestamp, value)` pairs. +- You will enforce validator allowlists, replay limits, and aggregation outside this library. + +## Import +```solidity +import {TrufAttestation} from "@trufnetwork/evm-contracts/contracts/attestation/TrufAttestation.sol"; +``` +Vendoring instead of installing? Keep the relative path; the library is pure Solidity and requires no deployment. + +## Canonical Payload Layout +Signed payload = `canonical || signature` + +| Field | Notes | +| --- | --- | +| `version (uint8)` | Currently `1`. | +| `algorithm (uint8)` | `0` = secp256k1 (only supported option today). | +| `blockHeight (uint64)` | TrufNetwork block height when produced. | +| `dataProvider (bytes4 + 20)` | Big-endian length prefix + provider address. | +| `streamId (bytes4 + 32)` | Big-endian length prefix + stream identifier bytes. | +| `actionId (uint16)` | Normalized query id; values 1–5 map to the `Action` enum. | +| `args (bytes4 + N)` | Length-prefixed ABI-encoded request arguments. | +| `result (bytes4 + M)` | Length-prefixed ABI-encoded `(uint256[], int256[])`. | +| `signature (65 bytes)` | Validator secp256k1 signature. | + +The signature covers fields 1–8: `sha256(canonicalFields)`. + +## Solidity Quickstart +```solidity +using TrufAttestation for bytes; + +function consume(bytes calldata payload, address validator) external { + TrufAttestation.Attestation memory att = TrufAttestation.parse(payload); + require(TrufAttestation.verify(att, validator), "unexpected signer"); + + TrufAttestation.DataPoint[] memory points = TrufAttestation.decodeDataPoints(att); + // Apply max-age checks, aggregation, etc. +} +``` + +Helpers such as `hash`, `metadata`, `body`, and `toAction` are also available—see the library source for details. + +## TypeScript Helpers +When crafting fixtures or unit tests, reuse the exported builders: +```ts +import { + buildCanonicalAttestation, + buildSignedAttestation, +} from "@trufnetwork/evm-contracts/src"; +``` +They mirror the canonical encoder maintained in the TrufNetwork node repo (`github.com/trufnetwork/node`, file `extensions/tn_attestation/canonical.go`) and power the Hardhat tests in `test/attestation/TrufAttestation.test.ts`. + +## Consumer Best Practices +- Maintain a governance-controlled validator allowlist and rotate proactively. +- Track digests/block heights to enforce replay and freshness policies. +- Aggregate multiple attestations (median/quorum) in downstream contracts if required. +- Surface verification failures in logs/metrics instead of swallowing them silently. diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index da7ea82..6b29d0d 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -64,6 +64,10 @@ Key differences from TNOracle: For custom implementations, we recommend reviewing the [Chainlink Functions documentation](https://docs.chain.link/chainlink-functions). +### TrufAttestation Library + +Contracts that ingest signed attestations directly can import `contracts/attestation/TrufAttestation.sol` to parse payloads, verify validator signatures, and decode `(timestamp, value)` pairs. The [Attestation Library guide](AttestationLibrary.md) covers payload structure, usage patterns, and the TypeScript helpers exposed in `src/` for building canonical fixtures. + ## Tasks Overview Our Hardhat tasks are organized into three main categories: @@ -139,4 +143,4 @@ For technical support or questions about integration: Remember that while TNOracle provides a standardized way to access TN data, you can also create custom implementations using Chainlink Functions directly if you need different data formats or computation patterns. -You can also interact directly with the TNOracle contract using your Externally Owned Account (EOA). This could be useful for testing or for making requests from a script. \ No newline at end of file +You can also interact directly with the TNOracle contract using your Externally Owned Account (EOA). This could be useful for testing or for making requests from a script. diff --git a/hardhat.config.cts b/hardhat.config.cts index 6c5c00c..77ffdf5 100644 --- a/hardhat.config.cts +++ b/hardhat.config.cts @@ -1,6 +1,8 @@ +import "dotenv/config"; +process.env.BCRYPTO_FORCE_FALLBACK ??= "1"; + import { subtask } from "hardhat/config"; import "@nomicfoundation/hardhat-toolbox"; -import "dotenv/config"; import { join } from "path"; import { writeFile } from "fs/promises"; import { TASK_COMPILE_SOLIDITY } from "hardhat/builtin-tasks/task-names"; @@ -41,7 +43,7 @@ subtask(TASK_COMPILE_SOLIDITY).setAction(async (_, { config }, runSuper) => { }); const PRIVATE_KEY = process.env.PRIVATE_KEY; - +const ETHEREUM_SEPOLIA_RPC_URL = process.env.ETHEREUM_SEPOLIA_RPC_URL; const ETHERSCAN_KEY = process.env.ETHERSCAN_API_KEY; /** @type import('hardhat/config').HardhatUserConfig */ @@ -65,10 +67,12 @@ const config = { url: "http://127.0.0.1:8545", chainId: 1337 }, - sepolia: { - url: process.env.ETHEREUM_SEPOLIA_RPC_URL, - accounts: [PRIVATE_KEY], - }, + ...(ETHEREUM_SEPOLIA_RPC_URL && { + sepolia: { + url: ETHEREUM_SEPOLIA_RPC_URL, + accounts: PRIVATE_KEY ? [PRIVATE_KEY] : undefined, + }, + }), }, etherscan: { apiKey: ETHERSCAN_KEY, diff --git a/src/attestation.ts b/src/attestation.ts new file mode 100644 index 0000000..033dc0b --- /dev/null +++ b/src/attestation.ts @@ -0,0 +1,72 @@ +import { ethers } from "ethers"; + +export type CanonicalAttestationFields = { + version: number; + algorithm: number; + blockHeight: bigint; + dataProvider: string; + streamId: string; + actionId: number; + args: Uint8Array; + result: Uint8Array; +}; + +/** + * Encode the canonical (unsigned) attestation payload. + */ +export function buildCanonicalAttestation(fields: CanonicalAttestationFields): Uint8Array { + const provider = ethers.getBytes(fields.dataProvider); + if (provider.length !== 20) { + throw new Error("dataProvider must be 20 bytes"); + } + + const stream = ethers.getBytes(fields.streamId); + if (stream.length !== 32) { + throw new Error("streamId must be 32 bytes"); + } + + const args = Uint8Array.from(fields.args); + const result = Uint8Array.from(fields.result); + + const pieces: Buffer[] = [ + Buffer.from([fields.version & 0xff]), + Buffer.from([fields.algorithm & 0xff]), + encodeUint64BE(fields.blockHeight), + lengthPrefix(Buffer.from(provider)), + lengthPrefix(Buffer.from(stream)), + encodeUint16BE(fields.actionId), + lengthPrefix(Buffer.from(args)), + lengthPrefix(Buffer.from(result)), + ]; + + return Buffer.concat(pieces); +} + +/** + * Append the signature bytes to the canonical payload. + */ +export function buildSignedAttestation(fields: CanonicalAttestationFields, signature: Uint8Array): Uint8Array { + if (signature.length !== 65) { + throw new Error("signature must be 65 bytes"); + } + const canonical = buildCanonicalAttestation(fields); + return Buffer.concat([Buffer.from(canonical), Buffer.from(signature)]); +} + +function encodeUint16BE(value: number): Buffer { + const buffer = Buffer.alloc(2); + buffer.writeUInt16BE(value & 0xffff, 0); + return buffer; +} + +function encodeUint64BE(value: bigint): Buffer { + const buffer = Buffer.alloc(8); + buffer.writeBigUInt64BE(value & BigInt("0xffffffffffffffff"), 0); + return buffer; +} + +function lengthPrefix(data: Buffer): Buffer { + const prefix = Buffer.alloc(4); + prefix.writeUInt32BE(data.length, 0); + return Buffer.concat([prefix, data]); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ba9ffdc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from "./attestation"; +export * from "./lib"; diff --git a/test/attestation/TrufAttestation.test.ts b/test/attestation/TrufAttestation.test.ts new file mode 100644 index 0000000..f0f9b83 --- /dev/null +++ b/test/attestation/TrufAttestation.test.ts @@ -0,0 +1,190 @@ +import fs from "fs"; +import path from "path"; +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { buildCanonical, buildPayload, CanonicalFields } from "../helpers/attestation"; + +const goldenPath = path.resolve( + __dirname, + "./fixtures/attestation_golden.json", +); +const goldenFixture = JSON.parse(fs.readFileSync(goldenPath, "utf8")) as { + canonical_hex: string; + signature_hex: string; + payload_hex: string; + data_provider: string; + stream_id: string; + block_height: number; + action_id: number; + args: { + data_provider: string; + stream_id: string; + start_time: number; + end_time: number; + pending_filter: null | string; + use_cache: boolean; + }; + result: { + timestamps: number[]; + values: string[]; + }; +}; + +const GOLDEN_CANONICAL = `0x${goldenFixture.canonical_hex}`; +const GOLDEN_SIGNATURE = `0x${goldenFixture.signature_hex}`; +const GOLDEN_PAYLOAD = `0x${goldenFixture.payload_hex}`; + +describe("TrufAttestation library", function () { + it("parses, hashes, verifies, and decodes data points", async function () { + const [deployer, other] = await ethers.getSigners(); + const harnessFactory = await ethers.getContractFactory("TrufAttestationHarness", deployer); + const harness = await harnessFactory.deploy(); + + const signingWallet = ethers.Wallet.createRandom(); + + const timestamps = [1n, 2n, 3n]; + const values = [BigInt(100) * 10n ** 18n, -BigInt(250) * 10n ** 18n, 0n]; + const abiEncodedResult = ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256[]", "int256[]"], + [timestamps, values] + ); + + const fields: CanonicalFields = { + version: 1, + algorithm: 0, + blockHeight: 123n, + dataProvider: signingWallet.address, + streamId: ethers.hexlify(ethers.randomBytes(32)), + actionId: 1, + args: ethers.getBytes("0x010203"), + result: ethers.getBytes(abiEncodedResult), + }; + + const canonical = buildCanonical(fields); + const digest = ethers.sha256(canonical); + + const signature = signingWallet.signingKey.sign(ethers.getBytes(digest)); + const serializedSignature = ethers.Signature.from(signature).serialized; + + const payload = buildPayload(fields, ethers.getBytes(serializedSignature)); + + const parsed = await harness.parse(payload); + expect(parsed.version).to.equal(fields.version); + expect(parsed.algorithm).to.equal(fields.algorithm); + expect(parsed.blockHeight).to.equal(fields.blockHeight); + expect(parsed.dataProvider).to.equal(signingWallet.address); + expect(parsed.streamId.toLowerCase()).to.equal(fields.streamId.toLowerCase()); + expect(parsed.actionId).to.equal(fields.actionId); + expect(Number(await harness.toAction(parsed.actionId))).to.equal(fields.actionId); + expect(parsed.args).to.equal(ethers.hexlify(fields.args)); + expect(parsed.result).to.equal(ethers.hexlify(fields.result)); + expect(parsed.signature).to.equal(serializedSignature); + + const hashed = await harness.hash(payload); + expect(hashed).to.equal(digest); + + expect(await harness.verify(payload, signingWallet.address)).to.equal(true); + expect(await harness.verify(payload, other.address)).to.equal(false); + + const decoded = await harness.decodeDataPoints(payload); + expect(decoded.length).to.equal(timestamps.length); + for (let i = 0; i < decoded.length; i++) { + expect(decoded[i].timestamp).to.equal(timestamps[i]); + expect(decoded[i].value).to.equal(values[i]); + } + + const [metaBlockHeight, metaProvider, metaStream, metaAction] = await harness.metadata(payload); + expect(metaBlockHeight).to.equal(fields.blockHeight); + expect(metaProvider).to.equal(signingWallet.address); + expect(metaStream).to.equal(fields.streamId); + expect(Number(metaAction)).to.equal(fields.actionId); + + const [bodyArgs, bodyResult] = await harness.body(payload); + expect(bodyArgs).to.equal(ethers.hexlify(fields.args)); + expect(bodyResult).to.equal(ethers.hexlify(fields.result)); + }); + + it("parses attestation payload from the golden fixture", async function () { + const harness = await (await ethers.getContractFactory("TrufAttestationHarness")).deploy(); + + const payload = GOLDEN_PAYLOAD; + const parsed = await harness.parse(payload); + + expect(parsed.version).to.equal(1); + expect(parsed.algorithm).to.equal(0); + expect(parsed.blockHeight).to.equal(BigInt(goldenFixture.block_height)); + expect(parsed.dataProvider).to.equal(ethers.getAddress(goldenFixture.data_provider)); + expect(parsed.streamId).to.equal( + ethers.hexlify(ethers.toUtf8Bytes(goldenFixture.stream_id)) + ); + const parsedActionId = Number(parsed.actionId); + expect(parsedActionId).to.equal(goldenFixture.action_id); + if (parsedActionId <= 5) { + expect(Number(await harness.toAction(parsedActionId))).to.equal(goldenFixture.action_id); + } else { + await expect(harness.toAction(parsedActionId)).to.be.revertedWithCustomError( + harness, + "AttestationUnexpectedActionId" + ); + } + + const streamLabel = ethers.toUtf8String(parsed.streamId); + expect(streamLabel).to.equal(goldenFixture.stream_id); + expect(parsed.signature).to.equal(GOLDEN_SIGNATURE); + + const points = await harness.decodeDataPoints(payload); + expect(points.length).to.equal(goldenFixture.result.timestamps.length); + for (let i = 0; i < points.length; i++) { + expect(points[i].timestamp).to.equal(BigInt(goldenFixture.result.timestamps[i])); + expect(points[i].value).to.equal(ethers.parseUnits(goldenFixture.result.values[i], 18)); + } + + const expectedValidator = ethers.getAddress(goldenFixture.data_provider); + expect(await harness.verify(payload, expectedValidator)).to.equal(true); + + const [blockHeight, provider, stream, action] = await harness.metadata(payload); + expect(blockHeight).to.equal(parsed.blockHeight); + expect(provider).to.equal(parsed.dataProvider); + expect(stream).to.equal(parsed.streamId); + expect(Number(action)).to.equal(goldenFixture.action_id); + + const [argsBytes, resultBytes] = await harness.body(payload); + expect(argsBytes).to.equal(parsed.args); + expect(resultBytes).to.equal(parsed.result); + + const payloadBytes = ethers.getBytes(payload); + const canonicalHexFromPayload = ethers.hexlify(payloadBytes.slice(0, -65)); + const signatureHexFromPayload = ethers.hexlify(payloadBytes.slice(-65)); + expect(canonicalHexFromPayload).to.equal(GOLDEN_CANONICAL); + expect(signatureHexFromPayload).to.equal(GOLDEN_SIGNATURE); + }); + + it("reverts verification for unsupported algorithm", async function () { + const [deployer] = await ethers.getSigners(); + const harness = await (await ethers.getContractFactory("TrufAttestationHarness", deployer)).deploy(); + + const signingWallet = ethers.Wallet.createRandom(); + + const abiEncodedResult = ethers.AbiCoder.defaultAbiCoder().encode(["uint256[]", "int256[]"], [[1n], [0n]]); + const fields: CanonicalFields = { + version: 1, + algorithm: 2, + blockHeight: 1n, + dataProvider: signingWallet.address, + streamId: ethers.hexlify(ethers.randomBytes(32)), + actionId: 1, + args: ethers.getBytes("0x"), + result: ethers.getBytes(abiEncodedResult), + }; + + const canonical = buildCanonical(fields); + const digest = ethers.sha256(canonical); + const signature = ethers.Signature.from(signingWallet.signingKey.sign(ethers.getBytes(digest))).serialized; + const payload = buildPayload(fields, ethers.getBytes(signature)); + + await expect(harness.verify(payload, signingWallet.address)).to.be.revertedWithCustomError( + harness, + "AttestationInvalidAlgorithm" + ); + }); +}); diff --git a/test/attestation/fixtures/attestation_golden.json b/test/attestation/fixtures/attestation_golden.json new file mode 100644 index 0000000..f0e0d03 --- /dev/null +++ b/test/attestation/fixtures/attestation_golden.json @@ -0,0 +1,21 @@ +{ + "canonical_hex": "01000000000000000141000000147e5f4552091a69125d5dfcb7b8c2659029395bdf00000020737434343238316464336562323236396161366135316438373663656635363400070000011a060000004600000000000f00000000000000000474657874000000000001002b000000013078376535663435353230393161363931323564356466636237623863323635393032393339356264663c00000000000f0000000000000000047465787400000000000100210000000173743434323831646433656232323639616136613531643837366365663536342400000000000f000000000000000004696e743800000000000100090000000100000000000003e82400000000000f000000000000000004696e743800000000000100090000000100000000000003e81700000000000f0000000000000000046e756c6c000000000000001d00000000000f000000000000000004626f6f6c00000000000100020000000100000000c000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000572b7b98736c20000", + "signature_hex": "3c56ffd4e1dc941a544ec3964837d46b24c234ec79392f3c20d92f0b40b483c409c071db4107eb39cfd65a1b95820fb8e77d54d588d5af982739aa6012371ace1b", + "payload_hex": "01000000000000000141000000147e5f4552091a69125d5dfcb7b8c2659029395bdf00000020737434343238316464336562323236396161366135316438373663656635363400070000011a060000004600000000000f00000000000000000474657874000000000001002b000000013078376535663435353230393161363931323564356466636237623863323635393032393339356264663c00000000000f0000000000000000047465787400000000000100210000000173743434323831646433656232323639616136613531643837366365663536342400000000000f000000000000000004696e743800000000000100090000000100000000000003e82400000000000f000000000000000004696e743800000000000100090000000100000000000003e81700000000000f0000000000000000046e756c6c000000000000001d00000000000f000000000000000004626f6f6c00000000000100020000000100000000c000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003e8000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000572b7b98736c200003c56ffd4e1dc941a544ec3964837d46b24c234ec79392f3c20d92f0b40b483c409c071db4107eb39cfd65a1b95820fb8e77d54d588d5af982739aa6012371ace1b", + "data_provider": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf", + "stream_id": "st44281dd3eb2269aa6a51d876cef564", + "block_height": 321, + "action_id": 7, + "args": { + "data_provider": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf", + "stream_id": "st44281dd3eb2269aa6a51d876cef564", + "start_time": 1000, + "end_time": 1000, + "pending_filter": null, + "use_cache": false + }, + "result": { + "timestamps": [1000], + "values": ["100.5"] + } +} diff --git a/test/helpers/attestation.ts b/test/helpers/attestation.ts new file mode 100644 index 0000000..9fd171f --- /dev/null +++ b/test/helpers/attestation.ts @@ -0,0 +1,5 @@ +export { + buildCanonicalAttestation as buildCanonical, + buildSignedAttestation as buildPayload, + type CanonicalAttestationFields as CanonicalFields, +} from "../../src/attestation";