The TRUF.NETWORK SDK provides a comprehensive interface for stream management, offering powerful primitives for data streaming, composition, and on-chain interactions.
Initializes a TrufNetwork client with specified configuration.
config: ObjectprivateKey: string- Ethereum private key (securely managed)network: Objectendpoint: string- RPC endpoint URLchainId: string- Network chain identifier
timeout?: number- Optional request timeout (default: 30000ms)
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
});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
});Generates a deterministic, unique stream identifier.
name: string- Descriptive name for the stream
Promise<StreamId>- Unique stream identifier
const marketIndexStreamId = await StreamId.generate('market_index');client.deployStream(streamId: StreamId, type: StreamType, synchronous?: boolean, allowZeros?: boolean): Promise<Types.GenericResponse<Types.TxReceipt>>
Deploys a new stream to the TRUF.NETWORK.
streamId: StreamId- Unique stream identifiertype: 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 whethervalue=0inserts are persisted. Defaultfalsepreserves the historical behavior (zeros are silently dropped on insert and excluded fromgetRecordresults). Settruefor streams where zero is a meaningful measurement. This setting can be toggled later viaaction.setAllowZeros.
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_hashis the broadcast transaction hash. The deploy is in the mempool (or, withsynchronous: true, mined) — pass the hash toclient.waitForTx(tx_hash)before issuing dependent operations such asinsertRecord.
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,
);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);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.
Permanently removes a stream from the network.
streamLocator: ObjectstreamId: StreamIddataProvider: EthereumAddress
await client.destroyStream({
streamId: marketIndexStreamId,
dataProvider: wallet.address
});Inserts a single record into a stream.
options: Objectstream: StreamLocator- Target streameventTime: number- UNIX timestamp of the record in seconds.value: string- Record value
const insertResult = await primitiveAction.insertRecord({
stream: streamLocator,
eventTime: Date.now(),
value: "100.50"
});Batch inserts multiple records for efficiency.
records: Array<InsertRecordOptions>- Array of record insertion options
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",
},
]);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
- Time window —
fromandtoare inclusive UNIX epoch timestamps in seconds. - 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. - 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). - Access control — Internally calls
is_allowed_to_read_allensuring the caller has permission to view every sub-stream referenced by a composed stream. - Performance — For large ranges prefer batching or add tighter
from/tofilters.
input: Objectstream: 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 bygetRecord; present only for signature compatibility with other helpers.
const nowInSeconds = Math.floor(Date.now() / 1000);
const { data: records } = await streamAction.getRecord(
marketIndexLocator,
{ from: nowInSeconds - 86400, to: nowInSeconds }
);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).
input: Objectstream: 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 beforefrozenAtonly)baseTime?: number– Reference timestamp (UNIX seconds) used for normalisation. If omitted, the SDK will try, in order:default_base_timemetadata on the stream- The first available record in the stream
Promise<StreamRecord[]>– Array of{ eventTime: number, value: string }representing indexed values.
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
}
);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.
input: Object- All properties from
GetRecordInput(stream,from,to,frozenAt,baseTime) timeInterval: number– Window size in seconds (e.g.86400for daily change,31536000for yearly change). Required.
- All properties from
Promise<StreamRecord[]>– Array of{ eventTime: number, value: string }wherevalueis the percentage change overtimeInterval.
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.
procedure: string– Name of the stored procedure.args: Record<string, ValueType | ValueType[]>– Named parameters including the leading$expected by Kwil.
Promise<StreamRecord[]>– Each row emitted by the procedure must exposeevent_timeandvaluecolumns for automatic mapping.
const result = await streamAction.customProcedureWithArgs(
"get_divergence_index_change",
{
$from: 1704067200,
$to: 1746316800,
$frozen_at: null,
$base_time: null,
$time_interval: 31536000,
},
);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.
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.warnis printed if you call them.
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
}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]
// }// 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!');
}Configures stream composition and weight distribution.
options: Objectstream: StreamLocator- Composed streamtaxonomyItems: Array<{childStream: StreamLocator, weight: string}>startDate: number- Effective date for taxonomy
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.
params?: Object- Optional query parametersfromHeight?: 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: 1000offset?: number- Number of results to skip for pagination. Default: 0latestOnly?: boolean- If true, returns only latest group_sequence per stream. Default: false
Promise<TaxonomyQueryResult[]>- Array of taxonomy entries with:dataProvider: EthereumAddress- Parent stream data providerstreamId: StreamId- Parent stream IDchildDataProvider: EthereumAddress- Child stream data providerchildStreamId: StreamId- Child stream IDweight: string- Weight of the child stream in the taxonomycreatedAt: number- Block height when taxonomy was createdgroupSequence: number- Group sequence number for this taxonomy setstartTime: number- Start time timestamp for this taxonomy
// 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.
params: Object- Query parameters (required)streams: StreamLocator[]- Array of stream locators to querylatestOnly?: boolean- If true, returns only latest group_sequence per stream. Default: false
Promise<TaxonomyQueryResult[]>- Array of taxonomy entries containing:dataProvider: EthereumAddress- Parent stream data providerstreamId: StreamId- Parent stream IDchildDataProvider: EthereumAddress- Child stream data providerchildStreamId: StreamId- Child stream IDweight: string- Weight of the child stream (0.0 to 1.0)createdAt: number- Block height when taxonomy was createdgroupSequence: number- Group sequence number for this taxonomy setstartTime: number- Start time timestamp for this taxonomy
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)
});
});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
});Controls stream read access.
await streamAction.setReadVisibility(
streamLocator,
visibility.private
);Grants read permissions to specific wallets.
await streamAction.allowReadWallet(
streamLocator,
EthereumAddress.fromString("0x...")
);Query transaction history, fees, and distributions for auditing and analytics.
Retrieves detailed information about a specific transaction by its hash.
input: ObjecttxId: string- Transaction hash (with or without0xprefix)
Promise<TransactionEvent>- Complete transaction details including:txId: string- Transaction hash (0x-prefixed)blockHeight: number- Block number where transaction was includedstampMs: 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
interface FeeDistribution {
recipient: string; // Recipient Ethereum address
amount: string; // Amount as string (handles large numbers)
}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.
input: Objectwallet: 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)
Promise<TransactionFeeEntry[]>- Array of transaction entries, each containing:txId: string- Transaction hashblockHeight: number- Block numbermethod: string- Method namecaller: string- Caller addresstotalFee: string- Total fee amountfeeRecipient?: 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.
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})`);
}// 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
});// 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`);// 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`);// 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`);
}// 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`);
}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
// ❌ 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 unexpectedlyWaits for transaction confirmation with optional timeout. Use this for operations where order matters.
txHash: string- Transaction hash from operation resulttimeout?: number- Maximum wait time in milliseconds (default: 30000)
Promise<TransactionReceipt>- Transaction receipt with confirmation status
// ✅ 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"
});- ✅ Stream deployment before data insertion
- ✅ Stream deletion before cleanup verification
- ✅ Sequential operations with dependencies
- ✅ Testing and development scenarios
- ⚡ High-throughput data insertion (independent records)
- ⚡ Fire-and-forget operations (with proper error handling)
Parses and decodes a canonical attestation payload (without the 65-byte signature).
payload: Uint8Array- Canonical payload bytes (full payload minus last 65 bytes)
ParsedAttestationPayloadobject with:version: number- Protocol version (currently 1)algorithm: number- Signature algorithm (0 = secp256k1)blockHeight: bigint- Block height when attestation was createddataProvider: string- Data provider Ethereum address (hex format)streamId: string- Stream identifieractionId: number- Action identifierarguments: any[]- Decoded action argumentsresult: DecodedRow[]- Decoded query results as rows (seeDecodedRow)
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}`);
});Represents a decoded row from attestation query results.
interface DecodedRow {
values: any[];
}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.)
- For attestation results:
// 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.
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"] },
// ...
]// 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}`);
});The SDK provides methods for interacting with bridge instances on TN, enabling token transfers between TN and supported blockchain networks.
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.
| 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.
Gets the wallet balance for a specific bridge instance.
bridgeIdentifier: string- Bridge instance identifier (e.g.,"sepolia","hoodi_tt","ethereum")walletAddress: string- Ethereum address to check balance for
Promise<string>- Balance in wei as a string (to handle large numbers safely)
// 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`);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.
options.token: "TRUF" | "USDC"- Which token's balance ledger to rankoptions.ascending?: boolean- Smallest balance first. Defaults tofalse(largest first)options.limit?: number- How many wallets to return. Defaults to20, and the node clamps it to a maximum of 50 — asking for more returns 50 rather than failingoptions.minBalance?: string- Only include wallets at or above this balance, in token base units. Defaults to no threshold
Promise<TokenBalance[]>- Matching wallets ordered by balance, each{ address, balance }.addressis 0x-prefixed lowercase hex;balanceis 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
numberloses data. A real mainnet TRUF balance such as685701000000000000000000has 24 digits, well pastNumber.MAX_SAFE_INTEGER(~9.0e15). UseBigIntfor arithmetic andformatUnitsfor display.
// 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`);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.
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
Promise<string>- Transaction hash of the withdrawal
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 processImportant Notes:
- Non-custodial bridges (Hoodi): You must manually claim withdrawals using
getWithdrawalProof() - Wait time: Withdrawals become claimable after the epoch period (typically 10 minutes)
Sends tokens from the caller to another in-network wallet via the bridge's public transfer action. Binds to the on-chain action <bridgeIdentifier>_transfer — eth_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.
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).
Promise<string>— Transaction hash of the transfer.
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.
bridgeIdentifier: string- Bridge instance identifier (e.g.,"hoodi_tt")walletAddress: string- Wallet address to get withdrawal proofs for
Promise<WithdrawalProof[]>- Array of withdrawal proofs (empty array if no unclaimed withdrawals)
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)
}// 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}`);
}
}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.
bridgeIdentifier: string- The unique identifier of the bridge (e.g., "hoodi_tt2")walletAddress: string- The wallet address to querylimit?: number- Max number of records to return (optional, default 20)offset?: number- Number of records to skip (optional, default 0)
Promise<BridgeHistory[]>- Array of history records
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}`);
}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.
getWithdrawalProof() instead, which provides a higher-level interface.
bridgeIdentifier: string- Bridge instance identifierwallet: string- Wallet address to querywithPending: boolean- Whether to include pending (not yet finalized) rewards
Promise<any[]>- Array of reward records
const action = client.loadAction();
const rewards = await action.listWalletRewards("hoodi_tt", walletAddress, true);
console.log(`Found ${rewards.length} reward(s)`);When integrating bridge functionality in your application:
- 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);- 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()");
}- 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");
}The Order Book API enables binary prediction markets on TRUF.NETWORK. Markets are automatically settled based on real-world data from trusted data providers.
const orderbook = client.loadOrderbookAction();Creates a new binary prediction market.
input: Objectbridge: BridgeIdentifier- Bridge for collateral ("hoodi_tt2","sepolia_bridge","ethereum_bridge")queryComponents: Uint8Array- ABI-encoded query tuple (useencodeQueryComponents())settleTime: number- Unix timestamp for market settlementmaxSpread: number- Maximum bid-ask spread (1-50 cents)minOrderSize: number- Minimum order size
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);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,
});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.
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)}`);Decodes the queryComponents field from a MarketInfo object into high-level structured data.
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"]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".
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,
});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}`);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
});Places a sell order for owned shares.
await orderbook.placeSellOrder({
queryId: market.id,
outcome: true,
price: 60,
amount: 50,
});Places a split limit order for market making. Atomically:
- Locks collateral ($1.00 per pair)
- Mints a YES/NO share pair
- Keeps YES shares as holdings
- 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,
});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
});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`);
}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`);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`);
}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.
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.
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:
availableSharesis 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.
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}`);
}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`);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.
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}`);
}
}| 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.
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.
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.
Settles a market after settlement time has passed.
const result = await orderbook.settleMarket(queryId);
await client.waitForTx(result.data!.tx_hash);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
| 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 |
// 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
);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).
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 },
);signer?: string— Operator secp256k1 key (hex, with or without the0xprefix). Required when the target node has[extensions.tn_local] require_signature = true. When set, every call carries a server-recoverable_authenvelope (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')"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. |
- No
signer+ serverrequire_signature = false→ request goes through unsigned. - No
signer+ serverrequire_signature = true→ server returnstn_local: unauthenticatedwithreason: "missing _auth". - Wrong
signer+ serverrequire_signature = true→ server returnstn_local: unauthenticatedwithreason: "signer is not this node's operator". - Operator
signer+ serverrequire_signature = true→ everylocal.*call succeeds;dataProvideris the operator address.
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.
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.
- Use batch record insertions
- Implement client-side caching
- Handle errors with specific catch blocks
- Minimum Node.js Version: 18.x