Skip to content

Latest commit

 

History

History
2064 lines (1635 loc) · 75.7 KB

File metadata and controls

2064 lines (1635 loc) · 75.7 KB

API Reference

Overview

The TRUF.NETWORK SDK provides a comprehensive interface for stream management, offering powerful primitives for data streaming, composition, and on-chain interactions.

Client Initialization

createClient(config: ClientConfig)

Initializes a TrufNetwork client with specified configuration.

Parameters

  • config: Object
    • privateKey: string - Ethereum private key (securely managed)
    • network: Object
      • endpoint: string - RPC endpoint URL
      • chainId: string - Network chain identifier
    • timeout?: number - Optional request timeout (default: 30000ms)

Example

import { createClient } from '@trufnetwork/sdk-js';

const client = createClient({
  privateKey: process.env.PRIVATE_KEY,
  network: {
    endpoint: 'http://localhost:8484',
    chainId: 'tn-v2.1' // Or left empty for local nodes
  },
  timeout: 45000  // Optional custom timeout
});

Timeout

All network calls have a timeout. You can override it with the timeout option:

const client = new NodeTNClient({
  // …other options…
  timeout: 45000, // Example of setting timeout to 45 seconds
});

Stream Identification

StreamId.generate(name: string): Promise<StreamId>

Generates a deterministic, unique stream identifier.

Parameters

  • name: string - Descriptive name for the stream

Returns

  • Promise<StreamId> - Unique stream identifier

Example

const marketIndexStreamId = await StreamId.generate('market_index');

Stream Deployment

client.deployStream(streamId: StreamId, type: StreamType, synchronous?: boolean, allowZeros?: boolean): Promise<Types.GenericResponse<Types.TxReceipt>>

Deploys a new stream to the TRUF.NETWORK.

Parameters

  • streamId: StreamId - Unique stream identifier
  • type: StreamType - Stream type (Primitive or Composed)
  • synchronous?: boolean - When true, the kwild gateway holds the request open until the deploy transaction is confirmed.
  • allowZeros?: boolean - Per-stream toggle controlling whether value=0 inserts are persisted. Default false preserves the historical behavior (zeros are silently dropped on insert and excluded from getRecord results). Set true for streams where zero is a meaningful measurement. This setting can be toggled later via action.setAllowZeros.

Returns

  • Promise<Types.GenericResponse<Types.TxReceipt>> — re-exported from @trufnetwork/kwil-js.
    • status: number - HTTP-style status from the kwild RPC.
    • data?: { tx_hash: string } - Present on success; tx_hash is the broadcast transaction hash. The deploy is in the mempool (or, with synchronous: true, mined) — pass the hash to client.waitForTx(tx_hash) before issuing dependent operations such as insertRecord.

Example

const deploymentResult = await client.deployStream(
        marketIndexStreamId,
        StreamType.Composed
);

// Stream where zero is a valid value:
await client.deployStream(
  hormuzStreamId,
  StreamType.Primitive,
  /* synchronous */ false,
  /* allowZeros */ true,
);

action.setAllowZeros(stream: StreamLocator, value: boolean)

Toggles the per-stream allow_zeros flag for an existing stream. Owner-gated.

The flip is forward-only — historical inserts are not rewritten. Zero records that were dropped before the flip stay dropped; zeros that arrive after the flip persist.

const action = client.loadAction();
await action.setAllowZeros({ streamId, dataProvider }, true);

action.getAllowZeros(stream: StreamLocator): Promise<boolean>

Returns the current allow_zeros setting for the stream. Returns false when the stream has no explicit metadata row, matching the implicit default applied at insert time.

Stream Destruction

client.destroyStream(streamLocator: StreamLocator): Promise<DestructionResult>

Permanently removes a stream from the network.

Parameters

  • streamLocator: Object
    • streamId: StreamId
    • dataProvider: EthereumAddress

Example

await client.destroyStream({
  streamId: marketIndexStreamId,
  dataProvider: wallet.address
});

Record Insertion

streamAction.insertRecord(options: InsertRecordOptions): Promise<InsertResult>

Inserts a single record into a stream.

Parameters

  • options: Object
    • stream: StreamLocator - Target stream
    • eventTime: number - UNIX timestamp of the record in seconds.
    • value: string - Record value

Example

const insertResult = await primitiveAction.insertRecord({
  stream: streamLocator,
  eventTime: Date.now(),
  value: "100.50"
});

streamAction.insertRecords(records: InsertRecordOptions[]): Promise<BatchInsertResult>

Batch inserts multiple records for efficiency.

Parameters

  • records: Array<InsertRecordOptions> - Array of record insertion options

Example

const batchResult = await primitiveAction.insertRecords([
	{
		stream: stockStream,
		eventTime: Math.floor(Date.now() / 1000),
		value: "150.25",
	},
	{
		stream: commodityStream,
		eventTime: Math.floor(Date.now() / 1000),
		value: "75.10",
	},
]);

Stream Querying

streamAction.getRecord(input: GetRecordInput): Promise<StreamRecord[]>

Retrieves the raw numeric values recorded in a stream for each timestamp. For primitive streams this is a direct read of the stored events; for composed streams the engine performs an on-the-fly aggregation of all underlying child streams using the active taxonomy and weights at each point in time.

The call is the foundation on which getIndex and getIndexChange are built—use it whenever you need the exact original numbers without any normalisation.

Key behaviours

  1. Time windowfrom and to are inclusive UNIX epoch timestamps in seconds.
  2. LOCF gap-filling — If no event exists exactly at from, the service automatically carries forward the last known value so that downstream analytics have a continuous series.
  3. Time-travel (frozenAt) — Supply a block-height timestamp to query the database as it looked in the past (i.e. ignore records created after that height).
  4. Access control — Internally calls is_allowed_to_read_all ensuring the caller has permission to view every sub-stream referenced by a composed stream.
  5. Performance — For large ranges prefer batching or add tighter from / to filters.

Parameters

  • input: Object
    • stream: StreamLocator – Target stream (primitive or composed)
    • from?: number – Optional start timestamp (UNIX seconds). If omitted returns the latest value.
    • to?: number – Optional end timestamp (UNIX seconds). Must be ≥ from.
    • frozenAt?: number – Optional created-at cut-off for historical queries.
    • baseTime?: number – Ignored by getRecord; present only for signature compatibility with other helpers.

Example

const nowInSeconds = Math.floor(Date.now() / 1000);
const { data: records } = await streamAction.getRecord(
	marketIndexLocator,
	{ from: nowInSeconds - 86400, to: nowInSeconds }
);

streamAction.getIndex(input: GetRecordInput): Promise<StreamRecord[]>

Transforms raw stream values into an "index" series normalised to a base value of 100 at a reference time. This is useful for turning any price/metric into a percentage-based index so that unrelated streams can be compared on the same scale.

The underlying formula (applied server-side, see get_index action) is:

index_t = (value_t * 100) / baseValue

where baseValue is the stream value obtained at baseTime (or the closest available value before/after that time if no exact sample exists).

Parameters

  • input: Object
    • stream: StreamLocator – Target stream (primitive or composed)
    • from?: number – Optional start timestamp (UNIX seconds).
    • to?: number – Optional end timestamp (UNIX seconds).
    • frozenAt?: number – Optional timestamp for "time-travel" queries (records created at or before frozenAt only)
    • baseTime?: number – Reference timestamp (UNIX seconds) used for normalisation. If omitted, the SDK will try, in order:
      1. default_base_time metadata on the stream
      2. The first available record in the stream

Returns

  • Promise<StreamRecord[]> – Array of { eventTime: number, value: string } representing indexed values.

Example

const nowInSeconds = Math.floor(Date.now() / 1000);
const { data: indexSeries } = await streamAction.getIndex(
	marketIndexLocator,
	{
		from: nowInSeconds - 30 * 24 * 60 * 60, // 30 days
		to: nowInSeconds,
		baseTime: nowInSeconds - 365 * 24 * 60 * 60, // One year ago
	}
);

streamAction.getIndexChange(input: GetRecordInput): Promise<StreamRecord[]>

Computes the percentage change of the index value over a fixed rolling window timeInterval.

For each returned eventTime the engine looks backwards by timeInterval seconds and picks the closest index value at or before that point. The change is then calculated as:

change_t = ((index_t − index_{t−Δ}) / index_{t−Δ}) * 100

This is equivalent to the classic Δ% formula used in financial analytics.

Parameters

  • input: Object
    • All properties from GetRecordInput (stream, from, to, frozenAt, baseTime)
    • timeInterval: number – Window size in seconds (e.g. 86400 for daily change, 31536000 for yearly change). Required.

Returns

  • Promise<StreamRecord[]> – Array of { eventTime: number, value: string } where value is the percentage change over timeInterval.

Example

const nowInSeconds = Math.floor(Date.now() / 1000);
const { data: yearlyChange } = await streamAction.getIndexChange(
	marketIndexLocator,
	{
		from: nowInSeconds - 2 * 365 * 24 * 60 * 60, // Last 2 years
		to: nowInSeconds,
		timeInterval: 31536000, // 1 year in seconds
	}
);
console.log("Year-on-year % change", yearlyChange);

streamAction.customProcedureWithArgs(procedure: string, args: Record<string, ValueType | ValueType[]>): Promise<StreamRecord[]>

Allows you to invoke any stored procedure defined in the underlying Kwil database and receive the results in StreamRecord format. Use this when the built-in helpers (getRecord, getIndex, getIndexChange) don't meet a specialised analytics need.

Parameters

  • procedure: string – Name of the stored procedure.
  • args: Record<string, ValueType | ValueType[]> – Named parameters including the leading $ expected by Kwil.

Returns

  • Promise<StreamRecord[]> – Each row emitted by the procedure must expose event_time and value columns for automatic mapping.

Example

const result = await streamAction.customProcedureWithArgs(
  "get_divergence_index_change",
  {
    $from: 1704067200,
    $to: 1746316800,
    $frozen_at: null,
    $base_time: null,
    $time_interval: 31536000,
  },
);

Cache Support

The SDK can transparently use a node-side cache layer (when the node has the tn_cache extension enabled). The feature is opt-in – you simply pass useCache: true inside the options object of any read helper and the same function now returns a wrapper that includes cache metadata.

What's new

  • useCache (boolean) – optional flag in all data-retrieval helpers (getRecord, getIndex, getIndexChange, getFirstRecord).
  • Return type becomes CacheAwareResponse<T> which contains:
    • data – the normal payload you used to receive.
    • cache{ hit: boolean; height?: number } when the node emitted cache metadata.
    • logs – raw NOTICE logs (useful for debugging).
  • Legacy signatures are still available but are deprecated – a one-time console.warn is printed if you call them.

Cache Metadata

The cache metadata includes both node-provided and SDK-enhanced fields:

interface CacheMetadata {
  // Node-provided fields
  hit: boolean;                    // Whether data came from cache
  cacheDisabled?: boolean;         // Whether cache was disabled for this query
  
  // SDK-provided context fields
  streamId?: string;              // Stream ID used in the query
  dataProvider?: string;          // Data provider address
  from?: number;                  // Start time of the query range
  to?: number;                    // End time of the query range
  frozenAt?: number;              // Frozen time for historical queries
  rowsServed?: number;            // Number of rows returned
}

Cache Aggregation

For batch operations or analytics, use CacheMetadataParser.aggregate() to combine multiple cache metadata entries:

import { CacheMetadataParser } from '@trufnetwork/sdk-js';

const metadataList: CacheMetadata[] = [
  { hit: true, rowsServed: 10, streamId: 'stream-1' },
  { hit: false, rowsServed: 5, streamId: 'stream-2' },
  { hit: true, rowsServed: 15, streamId: 'stream-3' }
];

const aggregated = CacheMetadataParser.aggregate(metadataList);
// Returns: CacheMetadataCollection
// {
//   totalQueries: 3,
//   cacheHits: 2,
//   cacheMisses: 1,
//   cacheHitRate: 0.67,
//   totalRowsServed: 30,
//   entries: [...metadataList]
// }

Quick example

// Enhanced call – identical parameters plus the flag
const { data: records, cache } = await streamAction.getRecord(
        aiIndexLocator,
        { from: now - 86400, to: now, useCache: true },
);

if (cache?.hit) {
  console.log('Cache hit!');
}

Composition Management

composedAction.setTaxonomy(options: TaxonomyConfig): Promise<TaxonomyResult>

Configures stream composition and weight distribution.

Parameters

  • options: Object
    • stream: StreamLocator - Composed stream
    • taxonomyItems: Array<{childStream: StreamLocator, weight: string}>
    • startDate: number - Effective date for taxonomy

Example

await composedAction.setTaxonomy({
	stream: composedMarketIndexLocator,
	taxonomyItems: [
		{ childStream: stockStream, weight: "0.6" },
		{ childStream: commodityStream, weight: "0.4" },
	],
	startDate: Math.floor(Date.now() / 1000),
});

composedAction.listTaxonomiesByHeight(params?: ListTaxonomiesByHeightParams): Promise<TaxonomyQueryResult[]>

Queries taxonomies within a specific block height range for efficient incremental synchronization. This method enables detecting taxonomy changes since a specific block height without expensive full-stream scanning.

Parameters

  • params?: Object - Optional query parameters
    • fromHeight?: number - Start height (inclusive). If null, uses earliest available.
    • toHeight?: number - End height (inclusive). If null, uses current height.
    • limit?: number - Maximum number of results to return. Default: 1000
    • offset?: number - Number of results to skip for pagination. Default: 0
    • latestOnly?: boolean - If true, returns only latest group_sequence per stream. Default: false

Returns

  • Promise<TaxonomyQueryResult[]> - Array of taxonomy entries with:
    • dataProvider: EthereumAddress - Parent stream data provider
    • streamId: StreamId - Parent stream ID
    • childDataProvider: EthereumAddress - Child stream data provider
    • childStreamId: StreamId - Child stream ID
    • weight: string - Weight of the child stream in the taxonomy
    • createdAt: number - Block height when taxonomy was created
    • groupSequence: number - Group sequence number for this taxonomy set
    • startTime: number - Start time timestamp for this taxonomy

Example

// Get taxonomies created between blocks 1000 and 2000
const taxonomies = await composedAction.listTaxonomiesByHeight({
  fromHeight: 1000,
  toHeight: 2000,
  limit: 100,
  latestOnly: true
});

// Get latest taxonomies with pagination
const latestTaxonomies = await composedAction.listTaxonomiesByHeight({
  latestOnly: true,
  limit: 50,
  offset: 100
});

composedAction.getTaxonomiesForStreams(params: GetTaxonomiesForStreamsParams): Promise<TaxonomyQueryResult[]> 🔍

Batch fetches taxonomies for specific streams. This is the primary method for discovering stream composition relationships. Useful for validating taxonomy data for known streams or processing multiple streams efficiently.

Parameters

  • params: Object - Query parameters (required)
    • streams: StreamLocator[] - Array of stream locators to query
    • latestOnly?: boolean - If true, returns only latest group_sequence per stream. Default: false

Returns

  • Promise<TaxonomyQueryResult[]> - Array of taxonomy entries containing:
    • dataProvider: EthereumAddress - Parent stream data provider
    • streamId: StreamId - Parent stream ID
    • childDataProvider: EthereumAddress - Child stream data provider
    • childStreamId: StreamId - Child stream ID
    • weight: string - Weight of the child stream (0.0 to 1.0)
    • createdAt: number - Block height when taxonomy was created
    • groupSequence: number - Group sequence number for this taxonomy set
    • startTime: number - Start time timestamp for this taxonomy

Example

const streams = [
  { dataProvider: provider1, streamId: streamId1 },
  { dataProvider: provider2, streamId: streamId2 }
];

const taxonomies = await composedAction.getTaxonomiesForStreams({
  streams,
  latestOnly: true
});

// Process results for each stream
taxonomies.forEach(taxonomy => {
  console.log(`Stream ${taxonomy.streamId.getId()} has child ${taxonomy.childStreamId.getId()} with weight ${taxonomy.weight}`);
});

// Example: Build a taxonomy map for visualization
const taxonomyMap = new Map();
taxonomies.forEach(taxonomy => {
  const parentId = taxonomy.streamId.getId();
  if (!taxonomyMap.has(parentId)) {
    taxonomyMap.set(parentId, []);
  }
  taxonomyMap.get(parentId).push({
    childId: taxonomy.childStreamId.getId(),
    weight: parseFloat(taxonomy.weight)
  });
});

High-Level Client Methods

The new taxonomy querying methods are also available directly on the client for convenience:

// Equivalent to composedAction.listTaxonomiesByHeight()
const taxonomies = await client.listTaxonomiesByHeight({
  fromHeight: 1000,
  toHeight: 2000,
  limit: 100,
  latestOnly: true
});

// Equivalent to composedAction.getTaxonomiesForStreams()
const streamTaxonomies = await client.getTaxonomiesForStreams({
  streams: [streamLocator1, streamLocator2],
  latestOnly: true
});

Visibility and Permissions

streamAction.setReadVisibility(streamLocator: StreamLocator, visibility: Visibility)

Controls stream read access.

Example

await streamAction.setReadVisibility(
        streamLocator,
        visibility.private
);

streamAction.allowReadWallet(streamLocator: StreamLocator, walletAddress: EthereumAddress)

Grants read permissions to specific wallets.

Example

await streamAction.allowReadWallet(
        streamLocator,
        EthereumAddress.fromString("0x...")
);

Transaction Ledger Queries

Query transaction history, fees, and distributions for auditing and analytics.

transactionAction.getTransactionEvent(input: GetTransactionEventInput): Promise<TransactionEvent>

Retrieves detailed information about a specific transaction by its hash.

Parameters

  • input: Object
    • txId: string - Transaction hash (with or without 0x prefix)

Returns

  • Promise<TransactionEvent> - Complete transaction details including:
    • txId: string - Transaction hash (0x-prefixed)
    • blockHeight: number - Block number where transaction was included
    • stampMs: number - Millisecond timestamp from the block header (0 when unavailable)
    • method: string - Method name (e.g., "deployStream", "insertRecords")
    • caller: string - Ethereum address of the caller (lowercase, 0x-prefixed)
    • feeAmount: string - Total fee amount as string (handles large numbers)
    • feeRecipient?: string - Primary fee recipient address (optional)
    • metadata?: string - Optional metadata JSON (optional)
    • feeDistributions: FeeDistribution[] - Array of fee distributions

FeeDistribution Type

interface FeeDistribution {
  recipient: string;  // Recipient Ethereum address
  amount: string;     // Amount as string (handles large numbers)
}

Example

const transactionAction = client.loadTransactionAction();

const txEvent = await transactionAction.getTransactionEvent({
  txId: '0xabcdef123456...'
});

console.log(`Method: ${txEvent.method}`);
console.log(`Caller: ${txEvent.caller}`);
console.log(`Fee: ${txEvent.feeAmount} wei`);
console.log(`Block: ${txEvent.blockHeight}`);
console.log(`Timestamp: ${txEvent.stampMs}`);

// Check fee distributions
for (const dist of txEvent.feeDistributions) {
  console.log(`  → ${dist.recipient}: ${dist.amount} wei`);
}

transactionAction.listTransactionFees(input: ListTransactionFeesInput): Promise<TransactionFeeEntry[]>

Lists transactions filtered by wallet address and mode, with pagination support.

Parameters

  • input: Object
    • wallet: string - Ethereum address to query (required)
    • mode?: 'paid' | 'received' | 'both' - Filter mode (optional, default: 'paid'):
      • 'paid' - Transactions where wallet paid fees
      • 'received' - Transactions where wallet received fee distributions
      • 'both' - All transactions involving the wallet
    • limit?: number - Maximum transactions to return (optional, default: 20; the node errors above 1000). The node paginates before expanding each transaction into one row per fee distribution, so the returned array can be longer than this.
    • offset?: number - Transactions to skip, for pagination (optional, default: 0)

Returns

  • Promise<TransactionFeeEntry[]> - Array of transaction entries, each containing:
    • txId: string - Transaction hash
    • blockHeight: number - Block number
    • method: string - Method name
    • caller: string - Caller address
    • totalFee: string - Total fee amount
    • feeRecipient?: string - Primary recipient (optional)
    • metadata?: string - Optional metadata (optional)
    • distributionSequence: number - Distribution index (for multiple distributions)
    • distributionRecipient?: string - Recipient address for this distribution (optional)
    • distributionAmount?: string - Amount for this distribution (optional)

Note: This method returns one row per fee distribution. If a transaction has multiple distributions, it will appear multiple times with different distributionSequence values.

Example - List Fees Paid

const transactionAction = client.loadTransactionAction();
const wallet = client.address().getAddress();

const entries = await transactionAction.listTransactionFees({
  wallet,
  mode: 'paid',
  limit: 10
});

for (const entry of entries) {
  console.log(`${entry.method}: ${entry.totalFee} wei (block ${entry.blockHeight})`);
}

Example - Pagination

// Get first page
const page1 = await transactionAction.listTransactionFees({
  wallet,
  mode: 'both',
  limit: 20,
  offset: 0
});

// Get second page
const page2 = await transactionAction.listTransactionFees({
  wallet,
  mode: 'both',
  limit: 20,
  offset: 20
});

Example - Fees Received

// Track fee distributions received by a validator
const entries = await transactionAction.listTransactionFees({
  wallet: validatorAddress,
  mode: 'received',
  limit: 100
});

let totalReceived = BigInt(0);
for (const entry of entries) {
  if (entry.distributionAmount) {
    totalReceived += BigInt(entry.distributionAmount);
  }
}

console.log(`Total fees received: ${totalReceived} wei`);

Use Cases

Auditing: Track Monthly Spending

// Calculate total fees paid by wallet
const entries = await transactionAction.listTransactionFees({
  wallet: myWallet,
  mode: 'paid'
});

let totalSpent = BigInt(0);
for (const entry of entries) {
  totalSpent += BigInt(entry.totalFee);
}

console.log(`Total spent: ${totalSpent} wei`);

Analytics: Transaction Patterns

// Analyze transaction types and their costs
const methodCounts = new Map<string, number>();
const methodCosts = new Map<string, bigint>();

const entries = await transactionAction.listTransactionFees({
  wallet: myWallet,
  mode: 'paid'
});

for (const entry of entries) {
  methodCounts.set(entry.method, (methodCounts.get(entry.method) || 0) + 1);
  methodCosts.set(
    entry.method,
    (methodCosts.get(entry.method) || BigInt(0)) + BigInt(entry.totalFee)
  );
}

for (const [method, count] of methodCounts) {
  const avgCost = methodCosts.get(method)! / BigInt(count);
  console.log(`${method}: ${count} calls, avg cost ${avgCost} wei`);
}

Fee Distribution Tracking

// Monitor where your fees are going
const txEvent = await transactionAction.getTransactionEvent({
  txId: deployTxHash
});

console.log(`Transaction: ${txEvent.txId}`);
console.log(`Total Fee: ${txEvent.feeAmount} wei`);
console.log('\nFee Distributions:');

for (let i = 0; i < txEvent.feeDistributions.length; i++) {
  const dist = txEvent.feeDistributions[i];
  console.log(`  ${i + 1}. ${dist.recipient}: ${dist.amount} wei`);
}

Transaction Handling

Understanding Async Transaction Behavior ⚠️

Critical Understanding: TN operations return success when transactions enter the mempool, NOT when they're executed on-chain. For operations where order matters, you must wait for transactions to be mined before proceeding.

💡 See Complete Example: For a comprehensive demonstration of transaction lifecycle patterns, see Transaction Lifecycle Example

The Race Condition Problem

// ❌ DANGEROUS - Race condition possible
const deployResult = await client.deployStream(streamId, StreamType.Primitive);
// Stream might not be ready yet!
await primitiveAction.insertRecord({ stream: client.ownStreamLocator(streamId), ... }); // Could fail

const destroyResult = await client.destroyStream(client.ownStreamLocator(streamId));
// Stream might not be destroyed yet!
await primitiveAction.insertRecord({ stream: client.ownStreamLocator(streamId), ... }); // Could succeed unexpectedly

client.waitForTx(txHash: string, timeout?: number): Promise<TransactionReceipt>

Waits for transaction confirmation with optional timeout. Use this for operations where order matters.

Parameters

  • txHash: string - Transaction hash from operation result
  • timeout?: number - Maximum wait time in milliseconds (default: 30000)

Returns

  • Promise<TransactionReceipt> - Transaction receipt with confirmation status

Safe Pattern Example

// ✅ SAFE - Explicit transaction confirmation
const deployResult = await client.deployStream(streamId, StreamType.Primitive);
if (!deployResult.data) {
  throw new Error('Deploy failed');
}

// Wait for deployment to complete
await client.waitForTx(deployResult.data.tx_hash);

// Now safe to proceed
await primitiveAction.insertRecord({
  stream: client.ownStreamLocator(streamId),
  eventTime: Math.floor(Date.now() / 1000),
  value: "100.50"
});

When to Use waitForTx:

  • Stream deployment before data insertion
  • Stream deletion before cleanup verification
  • Sequential operations with dependencies
  • Testing and development scenarios

When Async is Acceptable:

  • 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)

Example

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

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

// 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:

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:

[
  { values: ["1704067200", "77.051806494788211665"] },
  { values: ["1704153600", "78.718654581755352351"] },
  // ...
]

Complete Attestation Workflow

// 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}`);
});

Bridge Operations

The SDK provides methods for interacting with bridge instances on TN, enabling token transfers between TN and supported blockchain networks.

Understanding Bridge Identifiers

Bridge instances on TN are identified by specific names that may differ from network names. Always use the bridge identifier when calling bridge methods, not the network name.

Mainnet vs testnet identifiers

Identifier Network Token Decimals Notes
eth_truf mainnet TRUF 18 Used for protocol fees (stream write, attestation, market creation)
eth_usdc mainnet USDC 6 Used for prediction-market collateral
ethereum_bridge mainnet TRUF 18 Legacy — replaced by eth_truf
hoodi_tt testnet TRUF (test) 18 Hoodi testnet
hoodi_tt2 testnet USDC (test) 18 Hoodi testnet — prediction-market collateral
sepolia_bridge testnet TRUF (test) 18 Sepolia testnet, deprecated

Examples below use testnet identifiers; substitute the mainnet equivalent for production. Order-book actions (createMarket, placeBuyOrder, etc.) accept "eth_usdc" or "eth_truf" as the bridge field on mainnet.

client.getWalletBalance(bridgeIdentifier: string, walletAddress: string): Promise<string>

Gets the wallet balance for a specific bridge instance.

Parameters

  • bridgeIdentifier: string - Bridge instance identifier (e.g., "sepolia", "hoodi_tt", "ethereum")
  • walletAddress: string - Ethereum address to check balance for

Returns

  • Promise<string> - Balance in wei as a string (to handle large numbers safely)

Example

// Simple case - identifier matches network name
const sepoliaBalance = await client.getWalletBalance("sepolia", "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");
console.log(`Balance: ${sepoliaBalance} wei`);

// Multi-token bridge - specify bridge instance explicitly
const hoodiBalance = await client.getWalletBalance("hoodi_tt", "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");

// Convert wei to human-readable format
import { formatEther } from 'ethers';
const balanceInTokens = formatEther(hoodiBalance);
console.log(`Balance: ${balanceInTokens} tokens`);

client.getOrderedBalances(options: OrderedBalancesOptions): Promise<TokenBalance[]>

Gets a token's wallet balances in balance order — a "richlist". Where getWalletBalance reads one wallet on one bridge, this ranks every holder of a token and returns the top (or bottom) slice.

Parameters

  • options.token: "TRUF" | "USDC" - Which token's balance ledger to rank
  • options.ascending?: boolean - Smallest balance first. Defaults to false (largest first)
  • options.limit?: number - How many wallets to return. Defaults to 20, and the node clamps it to a maximum of 50 — asking for more returns 50 rather than failing
  • options.minBalance?: string - Only include wallets at or above this balance, in token base units. Defaults to no threshold

Returns

  • Promise<TokenBalance[]> - Matching wallets ordered by balance, each { address, balance }. address is 0x-prefixed lowercase hex; balance is in token base units (18 decimals for TRUF, 6 for USDC), as a string.

Returns an empty array when no wallet clears the threshold — that is a legitimate result, not an error. An unsupported token throws.

Balances are strings, and converting them to number loses data. A real mainnet TRUF balance such as 685701000000000000000000 has 24 digits, well past Number.MAX_SAFE_INTEGER (~9.0e15). Use BigInt for arithmetic and formatUnits for display.

Example

// The 10 largest TRUF holders
const top = await client.getOrderedBalances({ token: "TRUF", limit: 10 });
for (const { address, balance } of top) {
  console.log(`${address}: ${balance}`);
}

// The smallest USDC holders still holding at least 1 USDC (6 decimals)
const small = await client.getOrderedBalances({
  token: "USDC",
  ascending: true,
  limit: 5,
  minBalance: "1000000",
});

// Comparing or summing balances — use BigInt, never Number
const total = top.reduce((sum, r) => sum + BigInt(r.balance), 0n);

// Display in human-readable units
import { formatUnits } from 'ethers';
console.log(`Top holder: ${formatUnits(top[0].balance, 18)} TRUF`);

client.withdraw(bridgeIdentifier: string, amount: string, recipient: string): Promise<string>

Initiates a withdrawal by bridging tokens from TN to a destination chain. This is a convenience method that calls bridgeTokens and waits for transaction confirmation.

Parameters

  • bridgeIdentifier: string - Bridge instance identifier (e.g., "sepolia", "hoodi_tt")
  • amount: string - Amount to withdraw in wei (as string to preserve precision)
  • recipient: string - Recipient address on the destination chain

Returns

  • Promise<string> - Transaction hash of the withdrawal

Example

import { parseEther } from 'ethers';

// Withdraw 100 tokens to Sepolia
const amount = parseEther("100"); // Convert to wei
const txHash = await client.withdraw("sepolia", amount.toString(), "0x742d35Cc...");

console.log(`Withdrawal initiated: ${txHash}`);

// For non-custodial bridges (like Hoodi), you must claim the withdrawal manually
// See getWithdrawalProof() for claiming process

Important Notes:

  • Non-custodial bridges (Hoodi): You must manually claim withdrawals using getWithdrawalProof()
  • Wait time: Withdrawals become claimable after the epoch period (typically 10 minutes)

client.transfer(bridgeIdentifier: string, recipient: string, amount: string): Promise<string>

Sends tokens from the caller to another in-network wallet via the bridge's public transfer action. Binds to the on-chain action <bridgeIdentifier>_transfereth_truf_transfer / eth_usdc_transfer on mainnet, ethereum_transfer / sepolia_transfer on dev/test.

The caller pays a 1-token action fee on top of amount, denominated in the same token as the bridge (1 TRUF for eth_truf, 1 USDC for eth_usdc). The action reverts if the caller balance is below amount + 1 token.

Parameters

  • bridgeIdentifier: string — Bridge / action namespace prefix (e.g. "eth_truf", "eth_usdc", "sepolia").
  • recipient: string — Destination wallet address (Ethereum 0x… format).
  • amount: string — Transfer amount in wei (as string to preserve precision).

Returns

  • Promise<string> — Transaction hash of the transfer.

Example — Refill bot pattern

import { parseEther } from "ethers";

// Top up an adapter wallet; budget an extra 1 TRUF for the action fee.
const txHash = await client.transfer(
  "eth_truf",
  "0xAdapterWallet...",
  parseEther("100").toString(), // 100 TRUF
);
console.log(`Refill TX Hash: ${txHash}`);

client.getWithdrawalProof(bridgeIdentifier: string, walletAddress: string): Promise<WithdrawalProof[]>

Gets withdrawal proofs for claiming withdrawals on non-custodial bridges. Returns merkle proofs and validator signatures needed for submitting claims to the destination chain contract.

Parameters

  • bridgeIdentifier: string - Bridge instance identifier (e.g., "hoodi_tt")
  • walletAddress: string - Wallet address to get withdrawal proofs for

Returns

  • Promise<WithdrawalProof[]> - Array of withdrawal proofs (empty array if no unclaimed withdrawals)

WithdrawalProof Type

interface WithdrawalProof {
  chain: string;           // Source chain name (e.g., "hoodi")
  chain_id: string;        // Numeric chain ID (e.g., "560048")
  contract: string;        // Bridge contract address on destination chain
  created_at: number;      // Block number when withdrawal was created
  recipient: string;       // Recipient wallet address
  amount: string;          // Withdrawal amount in wei
  block_hash: string;      // Kwil block hash (base64-encoded)
  root: string;            // Merkle root (base64-encoded)
  proofs: string[];        // Merkle proofs (base64-encoded, usually empty)
  signatures: string[];    // Validator signatures (base64-encoded, 65 bytes each)
}

Example - Check for Claimable Withdrawals

// Check for claimable withdrawals
const proofs = await client.getWithdrawalProof("hoodi_tt", "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");

if (proofs.length === 0) {
  console.log("No withdrawals ready to claim");
} else {
  console.log(`${proofs.length} withdrawal(s) ready to claim`);

  for (const proof of proofs) {
    console.log(`Amount: ${proof.amount} wei`);
    console.log(`Recipient: ${proof.recipient}`);
    console.log(`Contract: ${proof.contract}`);
  }
}

Example - Claim Withdrawal On-Chain

import { Contract, ethers } from 'ethers';

// 1. Get withdrawal proof from TN
const proofs = await client.getWithdrawalProof("hoodi_tt", walletAddress);
if (proofs.length === 0) {
  throw new Error("No withdrawals to claim");
}

const proof = proofs[0];

// 2. Decode base64 data for smart contract call
const blockHash = Buffer.from(proof.block_hash, 'base64');
const root = Buffer.from(proof.root, 'base64');
const merkleProofs = proof.proofs.map(p => Buffer.from(p, 'base64'));

// 3. Split signatures into v, r, s components
const signatures = proof.signatures.map(sig => {
  const sigBytes = Buffer.from(sig, 'base64');
  return {
    v: sigBytes[64],
    r: '0x' + sigBytes.slice(0, 32).toString('hex'),
    s: '0x' + sigBytes.slice(32, 64).toString('hex')
  };
});

// 4. Call bridge contract to claim withdrawal
const bridgeContract = new Contract(proof.contract, BRIDGE_ABI, signer);

const tx = await bridgeContract.claimWithdrawal(
  proof.recipient,
  proof.amount,
  '0x' + blockHash.toString('hex'),
  '0x' + root.toString('hex'),
  merkleProofs.map(p => '0x' + p.toString('hex')),
  signatures.map(s => ({ v: s.v, r: s.r, s: s.s }))
);

await tx.wait();
console.log(`Withdrawal claimed! Tx: ${tx.hash}`);

client.getHistory(bridgeIdentifier: string, walletAddress: string, limit?: number, offset?: number): Promise<BridgeHistory[]>

Retrieves the transaction history for a wallet on a specific bridge. This method is provided by the base action handler (client.loadAction()) and also exposed directly on the client instance (client.getHistory(...)) for convenience.

Parameters

  • bridgeIdentifier: string - The unique identifier of the bridge (e.g., "hoodi_tt2")
  • walletAddress: string - The wallet address to query
  • limit?: number - Max number of records to return (optional, default 20)
  • offset?: number - Number of records to skip (optional, default 0)

Returns

  • Promise<BridgeHistory[]> - Array of history records

Example

const history = await client.getHistory("hoodi_tt2", "0x...", 10, 0);

for (const rec of history) {
  console.log(`${rec.type} - Amount: ${rec.amount} - Status: ${rec.status}`);
}

BridgeHistory Type

interface BridgeHistory {
  type: string;                // "deposit", "withdrawal", "transfer"
  amount: string;              // NUMERIC(78,0) as string
  from_address: string | null; // Sender address (hex)
  to_address: string;          // Recipient address (hex)
  internal_tx_hash: string | null; // Kwil TX hash (base64)
  external_tx_hash: string | null; // Ethereum TX hash (base64)
  status: string;              // "completed", "pending_epoch", "claimed"
  block_height: number;        // Kwil block height
  block_timestamp: number;     // Kwil block timestamp
  external_block_height: number | null; // Ethereum block height
}

action.listWalletRewards(bridgeIdentifier: string, wallet: string, withPending: boolean): Promise<any[]>

Lists wallet rewards for a specific bridge instance. This is a low-level method that directly accesses the bridge extension namespace.

⚠️ Deprecated: Most users should use getWithdrawalProof() instead, which provides a higher-level interface.

Parameters

  • bridgeIdentifier: string - Bridge instance identifier
  • wallet: string - Wallet address to query
  • withPending: boolean - Whether to include pending (not yet finalized) rewards

Returns

  • Promise<any[]> - Array of reward records

Example

const action = client.loadAction();
const rewards = await action.listWalletRewards("hoodi_tt", walletAddress, true);
console.log(`Found ${rewards.length} reward(s)`);

Bridge Configuration Best Practices

When integrating bridge functionality in your application:

  1. Use bridge identifiers directly:
// Always use the exact bridge identifier
const balance = await client.getWalletBalance('hoodi_tt', address);
const sepoliaBalance = await client.getWalletBalance('sepolia', address);

// For multiple Hoodi bridges
const tt2Balance = await client.getWalletBalance('hoodi_tt2', address);
  1. Handle custodial vs non-custodial bridges differently:
const isCustodial = {
  ethereum: true,  // Auto-claimed
  sepolia: true,   // Auto-claimed
  hoodi_tt: false, // Manual claim required
};

if (isCustodial[bridgeId]) {
  console.log("Withdrawal will be automatically claimed");
} else {
  console.log("You must claim withdrawal manually using getWithdrawalProof()");
}
  1. Poll for withdrawal proofs on non-custodial bridges:
async function waitForClaimableWithdrawal(bridgeId: string, address: string, maxAttempts = 60) {
  for (let i = 0; i < maxAttempts; i++) {
    const proofs = await client.getWithdrawalProof(bridgeId, address);
    if (proofs.length > 0) {
      return proofs[0];
    }
    // Wait 10 seconds before checking again
    await new Promise(resolve => setTimeout(resolve, 10000));
  }
  throw new Error("Withdrawal not ready after 10 minutes");
}

Order Book Operations

The Order Book API enables binary prediction markets on TRUF.NETWORK. Markets are automatically settled based on real-world data from trusted data providers.

Loading the Order Book Action

const orderbook = client.loadOrderbookAction();

Market Operations

orderbook.createMarket(input: CreateMarketInput): Promise<TxReceipt>

Creates a new binary prediction market.

Parameters
  • input: Object
    • bridge: BridgeIdentifier - Bridge for collateral ("hoodi_tt2", "sepolia_bridge", "ethereum_bridge")
    • queryComponents: Uint8Array - ABI-encoded query tuple (use encodeQueryComponents())
    • settleTime: number - Unix timestamp for market settlement
    • maxSpread: number - Maximum bid-ask spread (1-50 cents)
    • minOrderSize: number - Minimum order size
Example
import { OrderbookAction } from "@trufnetwork/sdk-js";

const args = OrderbookAction.encodeActionArgs(
  dataProviderAddress,
  streamId,
  timestamp,
  "50000.00", // threshold
  frozenAt
);

const queryComponents = OrderbookAction.encodeQueryComponents(
  dataProviderAddress,
  streamId,
  "price_above_threshold",
  args
);

const result = await orderbook.createMarket({
  bridge: "hoodi_tt2",
  queryComponents,
  settleTime: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
  maxSpread: 10,
  minOrderSize: 1,
});

await client.waitForTx(result.data!.tx_hash);

orderbook.createPriceAboveThresholdMarket(input): Promise<TxReceipt>

Convenience method for creating "price above threshold" markets.

const result = await orderbook.createPriceAboveThresholdMarket({
  dataProvider: "0x4710a8d8f0d845da110086812a32de6d90d7ff5c",
  streamId: "stbtc0000000000000000000000000000",
  timestamp: Math.floor(Date.now() / 1000) + 3600,
  threshold: "50000.00",
  frozenAt: 0,
  bridge: "hoodi_tt2",
  settleTime: Math.floor(Date.now() / 1000) + 3600,
  maxSpread: 10,
  minOrderSize: 1,
});

orderbook.createIndexChangeInRangeMarket(input): Promise<TxReceipt>

Creates a market on how far a stream's index moved, rather than on the value it publishes: "will year-over-year inflation land between 2% and 3%?". A stream publishing an index level of 335 is struck at 2, not at 343.

The bounds are in percent, measured against the stream's own value one timeInterval earlier, and they are half-open[minChange, maxChange). A change landing exactly on a boundary belongs to the bucket above it, so a set of buckets tiles the number line without two of them settling YES.

const observedAt = Math.floor(Date.now() / 1000) + 3600;

const result = await orderbook.createIndexChangeInRangeMarket({
  dataProvider: "0x4710a8d8f0d845da110086812a32de6d90d7ff5c",
  streamId: "stcpiyoy000000000000000000000000",
  timestamp: observedAt,
  timeInterval: 31536000, // look back one year
  minChange: "2",
  maxChange: "3",
  frozenAt: 0,
  bridge: "eth_usdc",
  settleTime: observedAt,
  maxSpread: 10,
  minOrderSize: 1,
});

Omit either bound (or pass null) to strike an open tail, which is how the outer two buckets of a set are struck. Omitting both is rejected — that would be every outcome at once.

// The three buckets of one set: below 2%, [2%, 3%), and 3% or more.
const buckets = [
  { minChange: null, maxChange: "2" },
  { minChange: "2", maxChange: "3" },
  { minChange: "3", maxChange: null },
];

timestamp, timeInterval and baseTime all go into the market's hash and cannot be changed once it exists. baseTime is optional and, unlike frozenAt, has no 0 sentinel: omit it for the stream's own default base date.

Bounds are rendered as the chain stores a NUMERIC(36,18), so "2" and "2.0" produce the same market. A bound with more than 18 decimal places, more than 18 integer digits, or a non-zero magnitude below 1e-6 is rejected rather than silently rounded or reformatted.

Requires node migration 055. On a network without it, the market is created but can never be attested.

orderbook.getMarketInfo(queryId: number): Promise<MarketInfo>

Gets detailed information about a market. Returns MarketInfo object containing queryComponents bytes.

const market = await orderbook.getMarketInfo(queryId);
console.log(`Settle Time: ${new Date(market.settleTime * 1000)}`);

decodeMarketData(encoded: string | Uint8Array): MarketData

Decodes the queryComponents field from a MarketInfo object into high-level structured data.

Example

import { decodeMarketData } from "@trufnetwork/sdk-js";

const market = await orderbook.getMarketInfo(123);
const details = decodeMarketData(market.queryComponents);

console.log(`Type: ${details.type}`);             // e.g. "above"
console.log(`Thresholds: ${details.thresholds}`); // e.g. ["100000.0"]

MarketData Interface

interface MarketData {
  dataProvider: string;
  streamId: string;
  actionId: string;
  type: "above" | "below" | "between" | "equals" | "change_between" | "unknown";
  thresholds: string[]; // Formatted numeric values as strings
  timestamp: number | null;    // The point in the stream the query observes
  frozenAt: number | null;     // Block height the data is pinned to; null = latest
  baseTime: number | null;     // "change_between" only; null = the stream's default base
  timeInterval: number | null; // "change_between" only, in seconds, e.g. 31536000 for YoY
}

thresholds holds one entry per strike slot the action declares, in order. A "change_between" market may strike an open tail, which reads back as an empty string in place rather than as a shorter array — so ["", "2.000000000000000000"] is "below 2%", and dropping the empty entry would slide the surviving bound into the wrong slot and turn it into "2% or more".

orderbook.listMarkets(input?: ListMarketsInput): Promise<MarketSummary[]>

Lists markets with optional filtering.

// Get all settled markets
const markets = await orderbook.listMarkets({
  settledFilter: true, // true=settled, false=active, null=all
  limit: 100,
  offset: 0,
});

orderbook.validateMarketCollateral(queryId: number): Promise<MarketValidation>

Validates market collateral integrity (YES/NO token parity and vault balance).

const validation = await orderbook.validateMarketCollateral(queryId);
console.log(`Valid: ${validation.validCollateral}`);
console.log(`Total YES: ${validation.totalTrue}`);
console.log(`Total NO: ${validation.totalFalse}`);

Order Operations

orderbook.placeBuyOrder(input: PlaceOrderInput): Promise<TxReceipt>

Places a buy order for shares. Locks collateral: amount x price x 10^16 wei.

await orderbook.placeBuyOrder({
  queryId: market.id,
  outcome: true,  // true=YES, false=NO
  price: 55,      // 55 cents
  amount: 100,    // 100 shares
});

orderbook.placeSellOrder(input: PlaceOrderInput): Promise<TxReceipt>

Places a sell order for owned shares.

await orderbook.placeSellOrder({
  queryId: market.id,
  outcome: true,
  price: 60,
  amount: 50,
});

orderbook.placeSplitLimitOrder(input: PlaceSplitLimitOrderInput): Promise<TxReceipt>

Places a split limit order for market making. Atomically:

  1. Locks collateral ($1.00 per pair)
  2. Mints a YES/NO share pair
  3. Keeps YES shares as holdings
  4. Places NO shares as a sell order at (100 - truePrice) cents
// Create 100 pairs: YES holdings + NO sell orders at 45c
await orderbook.placeSplitLimitOrder({
  queryId: market.id,
  truePrice: 55,  // YES at 55c, NO at 45c
  amount: 100,
});

orderbook.cancelOrder(input: CancelOrderInput): Promise<TxReceipt>

Cancels an open order (cannot cancel holdings where price=0).

await orderbook.cancelOrder({
  queryId: market.id,
  outcome: true,
  price: 55, // Price of order to cancel
});

Query Operations

orderbook.getOrderBook(queryId: number, outcome: boolean): Promise<OrderBookEntry[]>

Gets the order book for a market outcome.

const yesOrders = await orderbook.getOrderBook(queryId, true);
for (const order of yesOrders) {
  const type = order.price < 0 ? "BUY" : order.price > 0 ? "SELL" : "HOLDING";
  console.log(`${type}: ${order.amount} shares at ${Math.abs(order.price)}c`);
}

orderbook.getBestPrices(queryId: number, outcome: boolean): Promise<BestPrices>

Gets the best bid and ask prices for an outcome.

const prices = await orderbook.getBestPrices(queryId, true);
console.log(`YES: Bid=${prices.bestBid}c, Ask=${prices.bestAsk}c, Spread=${prices.spread}c`);

orderbook.getMarketDepth(queryId: number, outcome: boolean): Promise<DepthLevel[]>

Gets aggregated volume at each price level.

const depth = await orderbook.getMarketDepth(queryId, true);
for (const level of depth) {
  console.log(`${level.price}c: ${level.totalAmount} shares`);
}

orderbook.getFullMarketDepth(queryId: number): Promise<FullDepthLevel[]>

Gets aggregated volume at each price level for both outcomes, from one read.

Same aggregation as getMarketDepth, for the whole market instead of one outcome, with each level tagged by the outcome it rests on. Rows arrive YES first then NO, price ascending within each.

One statement means one snapshot. Anything comparing the two outcomes to each other wants this rather than two getMarketDepth calls, because between two calls an order can land on one side and not the other. getMarketDepth is unchanged and stays the right call when you want one outcome — a depth chart, a market-making bot quoting one side.

const depth = await orderbook.getFullMarketDepth(queryId);
for (const level of depth) {
  const side = level.outcome ? "YES" : "NO";
  console.log(`${side} ${level.price}c: ${level.buyVolume} buy, ${level.sellVolume} sell`);
}

orderbook.getConsolidatedOrderBook(queryId: number, outcome?: boolean): Promise<ConsolidatedOrderBook>

Gets one outcome's book with the opposite outcome's quotes folded in, so you see every quote the chain will actually fill.

The two books of a binary market are two views of one position. A resting SELL NO at 93c is a standing bid for YES at 7c: a trader hits it by selling YES, both sides sell, and the matching engine burns the share pair. In the YES frame:

consolidated bids = YES bids + (100 - p for every NO ask)
consolidated asks = YES asks + (100 - p for every NO bid)

The sides swap: a NO ask arrives as a YES bid. outcome defaults to true, and the NO-framed book is the YES-framed book reflected. Costs one getFullMarketDepth read, so both sides are one snapshot of the chain and isCrossed describes a state the book was really in. This used to take two getMarketDepth calls, where an order landing between them could make the stitched ladder read as crossed when neither height was. Requires a node carrying get_full_market_depth.

const book = await orderbook.getConsolidatedOrderBook(queryId);
for (const level of book.asks) {
  console.log(`${level.price}c: ${level.total} shares (${level.native} direct, ${level.inverse} mint)`);
}
if (book.isCrossed) console.log("best bid is at or above best ask");

This is not a sweepable ladder. A direct same-outcome match crosses prices, but mint and burn fire only when the two prices sum to exactly 100. So one order fills every native level past its limit plus exactly one inverse level. Walking these levels the way you would walk getMarketDepth quotes fills the chain will not produce, which is why each level keeps native and inverse separate rather than only their sum.

A consolidated book can also sit crossed indefinitely: a YES bid at 61 against a NO bid at 45 shows a bid at 61 over an ask at 55, and 61 + 45 is not 100 so nothing matches. Render it rather than treating it as bad data.

reflectConsolidatedBook(book: ConsolidatedOrderBook): ConsolidatedOrderBook

Returns the same market in the opposite outcome's frame, with no second chain read.

Both outcome views come from one getFullMarketDepth response, so the opposite view is an exact reflection rather than new information: prices complement to 100, bids and asks swap, and native and inverse volume swap with them, because a resting order belongs to the other outcome once the frame flips.

import { reflectConsolidatedBook } from "@trufnetwork/sdk-js";

const yes = await orderbook.getConsolidatedOrderBook(queryId, true);
const no = reflectConsolidatedBook(yes);

Anything rendering both outcomes wants this rather than a second getConsolidatedOrderBook call. It halves the round trips, and it removes the chance of stitching two different moments of the chain into one view.

quoteConsolidatedBuy(levels, shares) / quoteConsolidatedSell(levels, shares)

Answers what an order of a given size will actually do against a consolidated ladder, so no caller has to re-derive the matching rules.

import { quoteConsolidatedBuy } from "@trufnetwork/sdk-js";

const book = await orderbook.getConsolidatedOrderBook(queryId);
const quote = quoteConsolidatedBuy(book.asks, 700);

console.log(`submit ${quote.limitPrice}c for ${quote.filledShares} shares, $${quote.estimatedTotalCost}`);
for (const fill of quote.fills) {
  console.log(`  ${fill.shares} @ ${fill.price}c by ${fill.path}`);
}

Pass book.asks to buy and book.bids to sell. Three things the returned quote makes explicit, each of which a hand-rolled ladder walk gets wrong:

  • availableShares is not the ladder's total. It is the most any single order can take, which is smaller whenever inverse volume rests at more than one price. A ladder summing to 350 can cap one order at 200.
  • Fillable size is not monotonic in the limit price. Raising the limit can lose the inverse level the fill was counting on, so the model evaluates every candidate price instead of walking down the ladder.
  • A sell pays its limit on every share. A direct match pays the seller the ask price and refunds the buyer the difference, so crediting each resting bid its own price overstates any sell reaching past one level.

fills carries each leg's path"direct", "mint" or "burn" — for callers that want to show how the order settles.

Each leg also carries two prices, and they are not the same thing. price is what a share on that leg pays or receives; levelPrice is the ladder level the liquidity rested at. They agree on every buy leg. They diverge on a sell, where a direct match pays the seller the submitted limit rather than each resting bid's own price, so one order can take three bids and be paid the same on all three. Anything rendering which levels an order consumed wants levelPrice; anything totalling money wants price.

Choosing the limit is the caller's policy, not the SDK's. These two apply one reasonable default: the limit that fills the most, cheapest for a buy and highest for a sell. A caller wanting a price ceiling, the least market impact, or a price something downstream already settled on uses quoteConsolidatedBuyAtPrice(levels, shares, limit) or quoteConsolidatedSellAtPrice(levels, shares, limit) instead.

A limit only counts if an order could carry it: a whole cent from 1 through 99, which is what the node accepts. isSubmittablePrice is that check. The model never chooses a limit that fails it, and the AtPrice variants quote nothing when handed one — availableShares is still filled in, so a zero fill beside a non-zero availableShares says the limit was the problem rather than the book.

The quote assumes the order reaches the front of the queue at its price. Matching is FIFO within a level, so an older order resting at the same price takes the counterparty first and the real fill comes up short.

orderbook.getUserPositions(): Promise<UserPosition[]>

Gets the caller's positions across all markets.

const positions = await orderbook.getUserPositions();
for (const pos of positions) {
  const type = pos.price === 0 ? "HOLDING" : pos.price < 0 ? "BUY" : "SELL";
  console.log(`Market ${pos.queryId}: ${pos.outcome ? "YES" : "NO"} ${type} ${pos.amount}`);
}

orderbook.getUserCollateral(): Promise<UserCollateral>

Gets the caller's total locked collateral.

const collateral = await orderbook.getUserCollateral();
console.log(`Total Locked: ${collateral.totalLocked} wei`);
console.log(`Buy Orders: ${collateral.buyOrdersLocked} wei`);
console.log(`Shares Value: ${collateral.sharesValue} wei`);

Market Forecasting

Prediction markets price ranges, not values. A five-bucket EPS market says "34% chance EPS lands between $2.06 and $2.21"; it never says "EPS will be $2.14". These helpers invert that, collapsing the order books across every bucket of one market into the single number they collectively imply.

market says                     ->  forecast says
"34% between 2.06 and 2.21"         "2.14, p10..p90 1.91..2.38"

This is the same algorithm as sdk-py's get_market_forecast, and the two are verified to produce the same numbers.

orderbook.getMarketForecast(queryIds: number[]): Promise<MarketForecast | null>

Collapses a market's bucket books into the single value they imply.

Parameters:

  • queryIds: number[] — The bucket query_ids of one market. Order does not matter; they are sorted by bound internally. See Finding a market's queryIds below.

Returns: A MarketForecast, or null when no bucket has a usable quote.

Throws: if fewer than two query_ids are given, if any is repeated, if they do not all belong to the same market, or if a market is missing the queryComponents needed to derive its bounds.

One forecast covers the buckets of one market. A repeated query_id would have its bucket counted twice, and mixing two markets would normalise unrelated probabilities into a single distribution — both are rejected rather than warned about. Buckets of one market differ only in their strike, so the identity compared is (dataProvider, streamId, bridge, settleTime, timestamp, frozenAt) — the bridge included because an identical question collateralised two ways is two markets with two separate books.

Cost: two order-book reads plus one market-info read per bucket. Both the YES and NO books are fetched, because on this venue a resting BUY NO at p is hittable by a BUY YES at 100-p (mint match), so NO liquidity is executable YES liquidity and ignoring it would discard real quotes.

const forecast = await orderbook.getMarketForecast([419, 420, 421, 422, 423]);
if (forecast) {
  console.log(forecast.value.toFixed(4)); // 2.1362

  // p10/p90 are null when the market has too few strikes to place them.
  const band =
    forecast.p10 !== null && forecast.p90 !== null
      ? `${forecast.p10.toFixed(4)}..${forecast.p90.toFixed(4)}`
      : "unresolved";
  console.log(band); // 1.9053..2.3792

  for (const bucket of forecast.buckets) {
    console.log(`  ${bucket.lower}-${bucket.upper}: ${(bucket.probability * 100).toFixed(1)}%`);
  }
  for (const warning of forecast.warnings) {
    console.log(`  ! ${warning}`);
  }
}

MarketForecast

Field Type Meaning
value number The point estimate: the median of the implied distribution
p10, p90 number | null The published band. The market implies an 80% chance the outcome lands inside it
marginOfError number Half the P10..P90 band. Not a standard error — see below
sigma number The band scaled to a normal-equivalent standard deviation
valueBasis ForecastBasis "interior", "tail", or "unresolved" — see below
p10Basis, p90Basis ForecastBasis Same flags, for each end of the band
method ForecastMethod "rank" normally, "discrete" on a degenerate book
buckets BucketEstimate[] Per-bucket detail
warnings string[] Book-quality problems worth surfacing
multimodal, nPeaks boolean, number Whether the book implies more than one peak
low, high number value -/+ marginOfError

Each BucketEstimate carries queryId, lower, upper, probability (normalised, sums to 1), rawProbability, confidence, oneSided and quoted. forecastToJSON(forecast) gives a flat, JSON-serialisable form.

marginOfError is a band, not a precision. It is half the P10..P90 spread, so it describes how uncertain the outcome is, not how tightly the book pins your estimate. Expect it to be large — on a live five-bucket EPS market, roughly 0.24 against a value of 2.14. Publishing it as "± 0.24" is correct; reading it as "our estimate is accurate to 0.24" is not.

Check the Basis flags before displaying a number. The outer two buckets are open-ended, so the books say nothing about how far out they extend. When a percentile falls inside one, an exponential tail model supplies the number and the corresponding flag reads "tail". "interior" means it was read between real strikes with no shape assumption. "unresolved" means the market has too few strikes to place it at all.

Read warnings. Unquoted or one-sided buckets, crossed books, and dutch-book deviations are reported rather than silently smoothed over.

Finding a market's queryIds

Each bucket is a separate market with its own query_id, so a "market" is a set of them. They can be reassembled from chain data alone: buckets of the same market share a data stream and a settlement time.

import { decodeMarketData } from "@trufnetwork/sdk-js";

const groups = new Map<string, number[]>();
for (const summary of await orderbook.listMarkets({ settledFilter: false, limit: 100 })) {
  const info = await orderbook.getMarketInfo(summary.id);
  // Legacy markets carry no query_components and cannot be decoded.
  if (!info.queryComponents || info.queryComponents.length === 0) continue;
  const marketData = decodeMarketData(info.queryComponents);
  const key = `${marketData.streamId}@${summary.settleTime}`;
  groups.set(key, [...(groups.get(key) ?? []), summary.id]);
}

// A complete market tiles the line: one "below" bucket, one "above", ranges
// between. A stream can also carry a market that is not part of a bucket set at
// all, so skip anything too small to forecast rather than letting it throw.
for (const [, queryIds] of groups) {
  if (queryIds.length < 2) continue;
  const forecast = await orderbook.getMarketForecast(queryIds);
}

A layout that does not tile the line is still estimated, with the problem reported in warnings rather than thrown.

Forecasting from your own book data

If you already hold the books, the algorithm is available as pure functions with no I/O. forecastFromDepth is the preferred entry point; it consolidates the YES and NO ladders itself.

import {
  forecastFromDepth,
  forecastFromBuckets,
  type BucketDepth,
  type BucketBook,
} from "@trufnetwork/sdk-js";

// Full ladders. Prices are positive 1-99 cents on every side.
const buckets: BucketDepth[] = [
  {
    lower: null,                          // null = open-ended outer bucket
    upper: 1.91,
    yesBids: [{ price: 4, size: 386 }],
    yesAsks: [],
    noBids: [{ price: 83, size: 25 }],
    noAsks: [{ price: 97, size: 6 }],
    queryId: 419,
  },
  // ... remaining buckets, ascending
];
const forecast = forecastFromDepth(buckets);

// Or, if you only have top-of-book (no consolidation, weaker estimate):
const fromQuotes = forecastFromBuckets([
  { lower: null, upper: 1.91, bestBid: 4, bestAsk: 17, queryId: 419 },
  // ...
] as BucketBook[]);

A complete market spans the whole line, with the first bucket open below (lower: null) and the last open above (upper: null). That is what the algorithm is designed for, but it is not enforced: an interior-only or gapped set still returns a forecast, with the problem reported in warnings. The mass beyond an unrepresented tail simply has nowhere to go, so treat those results accordingly.

bucketBoundsFromMarketData(marketData) converts the output of decodeMarketData into a { lower, upper } pair, handling the below, between, above, equals and change_between market types. Either side is null when that tail is open.

Note the units differ: change_between bounds are in percent, against the stream's value one timeInterval earlier, where the other types are in the stream's own units. Comparing bounds across market types is the caller's responsibility.

Settlement Operations

orderbook.settleMarket(queryId: number): Promise<TxReceipt>

Settles a market after settlement time has passed.

const result = await orderbook.settleMarket(queryId);
await client.waitForTx(result.data!.tx_hash);

Price Representation

Prices are represented as integers in cents (1-99):

  • A YES price of 60 means 60 cents, implying 60% probability
  • The complementary NO price is always 100 - YES_price

Order Types

Price Value Type Description
-99 to -1 Buy Order Bid to buy at |price| cents
0 Holding Shares owned (not listed)
1 to 99 Sell Order Ask to sell at price cents

Static Helper Methods

// Encode action arguments for query components
const args = OrderbookAction.encodeActionArgs(
  dataProvider,  // Ethereum address
  streamId,      // 32-char stream ID
  timestamp,     // Unix timestamp
  threshold,     // Price threshold (e.g., "50000.00")
  frozenAt       // Block height for data snapshot
);

// Encode full query components
const queryComponents = OrderbookAction.encodeQueryComponents(
  dataProvider,
  streamId,
  actionId,      // e.g., "price_above_threshold"
  args
);

Local (Off-Chain) Stream Actions

LocalActions talks to the node's admin JSON-RPC server (default port 8485) instead of the gateway. Local streams live off-chain on a single node: no consensus broadcast, no transaction fees, implicitly owned by the node operator. The server derives data_provider from the node's secp256k1 key — clients never supply it on the wire.

Admin operations are Node.js only; loadLocalActions does not exist on BrowserTNClient.

Use LocalActions when the data should stay on one node (private primitive data, composed streams that compose local children, node-operator tooling). Use NodeTNClient for anything that needs consensus (cross-node reads, role management, bridge operations).

Construction

Three equivalent entry points:

import {
  AdminClient,
  LocalActions,
  NodeTNClient,
} from "@trufnetwork/sdk-js";

// 1. Standalone — no NodeTNClient, no wallet, no chain id.
const local = new LocalActions(
  new AdminClient({ adminProvider: "http://127.0.0.1:8485" }),
);

// 2. Standalone + operator-key signing (required on
//    require_signature = true nodes).
const signed = new LocalActions(
  new AdminClient({ adminProvider: "http://127.0.0.1:8485" }),
  { signer: process.env.OPERATOR_KEY },
);

// 3. Share the admin transport with a full NodeTNClient app.
const client = new NodeTNClient({ /* endpoint, chainId, signer */ });
const viaClient = client.loadLocalActions(
  { adminProvider: "http://127.0.0.1:8485" },
  { signer: process.env.OPERATOR_KEY },
);

LocalActionsOptions

  • signer?: string — Operator secp256k1 key (hex, with or without the 0x prefix). Required when the target node has [extensions.tn_local] require_signature = true. When set, every call carries a server-recoverable _auth envelope (sig, ts, ver); the server rejects requests signed by any other key. Leave unset to talk to nodes with the flag off. Malformed hex is rejected at construction time.

Extract the autogenerated operator key from the dev container once:

export OPERATOR_KEY="$(docker exec tn-db cat /root/.kwild/nodekey.json | jq -r '.key')"

API surface

Every method below accepts only business inputs — no dataProvider is ever sent on the wire. Responses include dataProvider (server-derived, always the node's own address, lowercased).

Method Purpose
createStream({ streamId, streamType }) Create a local primitive or composed stream.
deleteStream({ streamId }) Remove a local stream and all child rows (records, taxonomies).
insertRecords({ streamId, eventTime, value }) Append records. Parallel arrays: streamId[i] gets (eventTime[i], value[i]).
insertTaxonomy({ streamId, childStreamIds, weights, startDate }) Add a taxonomy group to a composed stream.
disableTaxonomy({ streamId, groupSequence }) Soft-delete a taxonomy group.
getRecord({ streamId, fromTime?, toTime? }) Query records (latest if both bounds unset).
getIndex({ streamId, fromTime?, toTime?, baseTime? }) Query the computed index series.
listStreams() List every local stream on this node.

Auth behavior

  • No signer + server require_signature = false → request goes through unsigned.
  • No signer + server require_signature = true → server returns tn_local: unauthenticated with reason: "missing _auth".
  • Wrong signer + server require_signature = true → server returns tn_local: unauthenticated with reason: "signer is not this node's operator".
  • Operator signer + server require_signature = true → every local.* call succeeds; dataProvider is the operator address.

Transport auth

The admin server has its own transport-level auth (unix socket, mTLS, or --admin.pass) completely independent of tn_local's require_signature. Pick a transport that matches your trust model: loopback TCP + --admin.notls is fine for on-host dev work but is not equivalent to the default unix socket; any other process running as any user on the host can still reach it. For production, use the unix socket, mTLS, or --admin.pass. See node/docs/development.md for the full matrix.

Full example

A working script that creates a primitive stream, a composed stream with a taxonomy, queries both, and demonstrates wrong-key rejection lives at examples/local_actions_example/. Run it against either a flag-off or a flag-on node; its README walks through both invocation shapes.

Performance Recommendations

  • Use batch record insertions
  • Implement client-side caching
  • Handle errors with specific catch blocks

SDK Compatibility

  • Minimum Node.js Version: 18.x