From 96ab85fa1d6e1ba1e6a671b797eba8128b0b6b45 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Fri, 1 May 2026 10:04:04 +0700 Subject: [PATCH 1/6] feat: allow zero to be recorded in configured stream --- docs/api-reference.md | 25 ++++++++- src/client/client.ts | 2 + src/contracts-api/action.test.ts | 83 ++++++++++++++++++++++++++++- src/contracts-api/action.ts | 54 ++++++++++++++++++- src/contracts-api/contractValues.ts | 21 ++++++++ src/contracts-api/deployStream.ts | 29 ++++++++-- 6 files changed, 205 insertions(+), 9 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 2248307..788f747 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -60,12 +60,14 @@ const marketIndexStreamId = await StreamId.generate('market_index'); ## Stream Deployment -### `client.deployStream(streamId: StreamId, type: StreamType): Promise` +### `client.deployStream(streamId: StreamId, type: StreamType, synchronous?: boolean, allowZeros?: boolean): Promise` 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. Can be toggled later via `action.setAllowZeros`. #### Returns - `Promise` @@ -78,8 +80,29 @@ 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. + +```typescript +const action = client.loadAction(); +await action.setAllowZeros({ streamId, dataProvider }, true); +``` + +### `action.getAllowZeros(stream: StreamLocator): Promise` +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` diff --git a/src/client/client.ts b/src/client/client.ts index 75963c2..ba2a4db 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -128,11 +128,13 @@ export abstract class BaseTNClient { streamId: StreamId, streamType: StreamType, synchronous?: boolean, + allowZeros?: boolean, ): Promise> { return await deployStream({ streamId, streamType, synchronous, + allowZeros, kwilClient: this.getKwilClient(), kwilSigner: this.getKwilSigner(), }); diff --git a/src/contracts-api/action.test.ts b/src/contracts-api/action.test.ts index 484c7c1..f5197d5 100644 --- a/src/contracts-api/action.test.ts +++ b/src/contracts-api/action.test.ts @@ -1,5 +1,8 @@ -import { Action } from "./action"; +import { Action, ReservedMetadataKeyError } from "./action"; import { BridgeHistory } from "../types/bridge"; +import { MetadataKey, MetadataKeyValueMap, MetadataType } from "./contractValues"; +import { EthereumAddress } from "../util/EthereumAddress"; +import { StreamId } from "../util/StreamId"; import { KwilSigner, NodeKwil } from "@trufnetwork/kwil-js"; import { Either } from "monads-io"; import { vi, describe, it, expect } from "vitest"; @@ -63,7 +66,7 @@ describe("Action", () => { it("should use default parameters for getHistory", async () => { const action = new Action(mockKwil, mockSigner); const callSpy = vi.spyOn(action as any, "call"); - + callSpy.mockResolvedValue(Either.right([])); await action.getHistory("bridge", "0x123"); @@ -77,4 +80,80 @@ describe("Action", () => { } ); }); + + describe("allow_zeros wiring", () => { + // Locks in the metadata-key registration: a future rename in the + // node-side migration would silently break SDK callers without + // this assertion. + it("registers AllowZerosKey as a Bool metadata type", () => { + expect(MetadataKey.AllowZerosKey).toBe("allow_zeros"); + expect(MetadataKeyValueMap[MetadataKey.AllowZerosKey]).toBe(MetadataType.Bool); + }); + + it("setAllowZeros calls set_allow_zeros with $value=true", async () => { + const action = new Action(mockKwil, mockSigner); + const execSpy = vi.spyOn(action as any, "executeWithNamedParams"); + execSpy.mockResolvedValue({ data: { tx_hash: "abc" }, status: 200 }); + + const streamId = StreamId.fromString("st00000000000000000000000000a110").throw(); + const dp = EthereumAddress.fromString("0x000000000000000000000000000000000000a110").throw(); + + await action.setAllowZeros({ streamId, dataProvider: dp }, true); + + expect(execSpy).toHaveBeenCalledWith("set_allow_zeros", [{ + $data_provider: dp.getAddress(), + $stream_id: streamId.getId(), + $value: true, + }]); + }); + + it("getAllowZeros returns false when get_allow_zeros yields no rows", async () => { + const action = new Action(mockKwil, mockSigner); + const callSpy = vi.spyOn(action as any, "call"); + callSpy.mockResolvedValue(Either.right([])); + + const streamId = StreamId.fromString("st00000000000000000000000000a111").throw(); + const dp = EthereumAddress.fromString("0x000000000000000000000000000000000000a111").throw(); + + const v = await action.getAllowZeros({ streamId, dataProvider: dp }); + expect(v).toBe(false); + }); + + it("getAllowZeros returns true when get_allow_zeros yields allow_zeros=true", async () => { + const action = new Action(mockKwil, mockSigner); + const callSpy = vi.spyOn(action as any, "call"); + callSpy.mockResolvedValue(Either.right([{ allow_zeros: true }])); + + const streamId = StreamId.fromString("st00000000000000000000000000a112").throw(); + const dp = EthereumAddress.fromString("0x000000000000000000000000000000000000a112").throw(); + + const v = await action.getAllowZeros({ streamId, dataProvider: dp }); + expect(v).toBe(true); + }); + + // Mirrors the node-side guard: routing AllowZerosKey through + // the generic setMetadata path would let two parallel "latest" + // rows coexist. The TypeScript Exclude already blocks this at + // compile time; this runtime test catches the `as any` escape + // hatch and asserts the SDK throws ReservedMetadataKeyError + // before the node ever sees the request. + it("setMetadata throws ReservedMetadataKeyError when called with AllowZerosKey", async () => { + const action = new Action(mockKwil, mockSigner); + const execSpy = vi.spyOn(action as any, "executeWithNamedParams"); + + const streamId = StreamId.fromString("st00000000000000000000000000a113").throw(); + const dp = EthereumAddress.fromString("0x000000000000000000000000000000000000a113").throw(); + + // Cast through `any` to bypass the protected modifier and the + // type-level Exclude — we want to prove the runtime guard + // also fires for callers who reach the helper dynamically. + const setMetadata = (action as any).setMetadata.bind(action); + + await expect( + setMetadata({ streamId, dataProvider: dp }, MetadataKey.AllowZerosKey, true), + ).rejects.toBeInstanceOf(ReservedMetadataKeyError); + + expect(execSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/contracts-api/action.ts b/src/contracts-api/action.ts index 9048249..cc7bdd1 100644 --- a/src/contracts-api/action.ts +++ b/src/contracts-api/action.ts @@ -11,12 +11,22 @@ import { toVisibilityEnum, VisibilityEnum } from "../util/visibility"; import { CacheMetadataParser } from "../util/cacheMetadataParser"; import { CacheValidation } from "../util/cacheValidation"; import { + isReservedMetadataKey, MetadataKey, MetadataKeyValueMap, MetadataTableKey, MetadataValueTypeForKey, + MutableMetadataKey, StreamType, } from "./contractValues"; + +/** Thrown when a reserved metadata key is routed through setMetadata. */ +export class ReservedMetadataKeyError extends Error { + constructor(public readonly key: MetadataKey) { + super(`reserved metadata key '${key}': use the dedicated mutator (e.g. setAllowZeros)`); + this.name = "ReservedMetadataKeyError"; + } +} // ValueType is available as Types.ValueType export interface GetRecordInput { @@ -490,11 +500,17 @@ export class Action { }; } - protected async setMetadata( + protected async setMetadata( stream: StreamLocator, key: K, value: MetadataValueTypeForKey, ): Promise> { + // Runtime guard backs up the type-level Exclude. Catches callers + // who reach setMetadata via `as any` / dynamic dispatch and would + // otherwise hit the friendlier-but-still-runtime node-side error. + if (isReservedMetadataKey(key)) { + throw new ReservedMetadataKeyError(key); + } return await this.executeWithNamedParams("insert_metadata", [{ $data_provider: stream.dataProvider.getAddress(), $stream_id: stream.streamId.getId(), @@ -650,6 +666,42 @@ export class Action { .unwrapOr(null); } + /** + * Toggles the per-stream allow_zeros flag. Owner-gated. Forward-only: + * historical state is not rewritten by flipping the flag. + * + * Default behavior (no metadata row, allow_zeros=false) drops value=0 + * inserts. Setting allow_zeros=true persists zeros from that point on. + */ + public async setAllowZeros( + stream: StreamLocator, + value: boolean, + ): Promise> { + return await this.executeWithNamedParams("set_allow_zeros", [{ + $data_provider: stream.dataProvider.getAddress(), + $stream_id: stream.streamId.getId(), + $value: value, + }]); + } + + /** + * 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. + */ + public async getAllowZeros(stream: StreamLocator): Promise { + const result = await this.call<{ allow_zeros: boolean }[]>( + "get_allow_zeros", + { + $data_provider: stream.dataProvider.getAddress(), + $stream_id: stream.streamId.getId(), + }, + ); + return result + .mapRight((rows) => head(rows).map((row) => Boolean(row.allow_zeros)).unwrapOr(false)) + .throw(); + } + /** * Allows a wallet to read the stream */ diff --git a/src/contracts-api/contractValues.ts b/src/contracts-api/contractValues.ts index ac0252a..6fb5978 100644 --- a/src/contracts-api/contractValues.ts +++ b/src/contracts-api/contractValues.ts @@ -12,9 +12,29 @@ export const MetadataKey = { ReadVisibilityKey: "read_visibility", AllowReadWalletKey: "allow_read_wallet", AllowComposeStreamKey: "allow_compose_stream", + AllowZerosKey: "allow_zeros", } as const; export type MetadataKey = (typeof MetadataKey)[keyof typeof MetadataKey]; +/** + * Keys that may NOT flow through the generic setMetadata helper. They + * have dedicated mutators (e.g. setAllowZeros for AllowZerosKey) that + * own the disable-then-insert sequence atomically. Routing these + * through insert_metadata would let two parallel "latest" rows coexist + * and break the per-stream lookup that insert_records and + * helper_enqueue_prune_days rely on. The node-side action enforces + * this; the type-level constraint is for friendlier compile errors. + */ +export const RESERVED_METADATA_KEYS = [MetadataKey.AllowZerosKey] as const; +export type ReservedMetadataKey = (typeof RESERVED_METADATA_KEYS)[number]; + +/** MetadataKey union with reserved keys removed — accepted by setMetadata. */ +export type MutableMetadataKey = Exclude; + +export function isReservedMetadataKey(key: MetadataKey): key is ReservedMetadataKey { + return (RESERVED_METADATA_KEYS as readonly MetadataKey[]).includes(key); +} + export const MetadataType = { Int: "int", Bool: "bool", @@ -40,6 +60,7 @@ export const MetadataKeyValueMap = { [MetadataKey.ReadVisibilityKey]: MetadataType.Int, [MetadataKey.AllowReadWalletKey]: MetadataType.Ref, [MetadataKey.AllowComposeStreamKey]: MetadataType.Ref, + [MetadataKey.AllowZerosKey]: MetadataType.Bool, } as const satisfies Record; type MetadataValueMap = { diff --git a/src/contracts-api/deployStream.ts b/src/contracts-api/deployStream.ts index 7e257cc..bfedc9f 100644 --- a/src/contracts-api/deployStream.ts +++ b/src/contracts-api/deployStream.ts @@ -8,6 +8,12 @@ export interface DeployStreamInput { kwilClient: Types.Kwil; kwilSigner: KwilSigner; synchronous?: boolean; + /** + * Toggles per-stream persistence of value=0 inserts. Default false + * preserves the historical behavior — zeros are dropped on insert. + * Set true for streams where zero is a meaningful measurement. + */ + allowZeros?: boolean; } export interface DeployStreamOutput { @@ -23,13 +29,26 @@ export async function deployStream( input: DeployStreamInput, ): Promise> { try { + // Omit $allow_zeros from the named-parameter map when the caller + // didn't opt in. Pre-feature nodes don't know about $allow_zeros, + // so always sending it (even as `false`) risks rejection on + // older deployments. The action's DEFAULT FALSE preserves today's + // behavior when the parameter is absent. + const inputs = input.allowZeros === true + ? { + $stream_id: input.streamId.getId(), + $stream_type: input.streamType, + $allow_zeros: true, + } + : { + $stream_id: input.streamId.getId(), + $stream_type: input.streamType, + }; + const txHash = await input.kwilClient.execute( { namespace: "main", - inputs: [{ - $stream_id: input.streamId.getId(), - $stream_type: input.streamType, - }], + inputs: [inputs], name: "create_stream", description: `TN SDK - Deploying ${input.streamType} stream: ${input.streamId.getId()}` }, @@ -41,4 +60,4 @@ export async function deployStream( } catch (error) { throw new Error(`Failed to deploy stream: ${error}`); } -} \ No newline at end of file +} From e67a4b908f448bc792ab0386568439e38774e8a4 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Fri, 1 May 2026 10:11:41 +0700 Subject: [PATCH 2/6] chore: apply sugestion --- docs/api-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 788f747..48218a9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -67,7 +67,7 @@ Deploys a new stream to the TRUF.NETWORK. - `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. Can be toggled later via `action.setAllowZeros`. +- `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` From 36a6353dfd05e306552b571af4daf23000ede4e8 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Fri, 1 May 2026 16:27:25 +0700 Subject: [PATCH 3/6] chore: patch CI --- tests/integration/erc20Bridge.test.ts | 6 +- tests/integration/history.test.ts | 84 ++++++++++++++------------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/tests/integration/erc20Bridge.test.ts b/tests/integration/erc20Bridge.test.ts index b7010a8..a2eaa67 100644 --- a/tests/integration/erc20Bridge.test.ts +++ b/tests/integration/erc20Bridge.test.ts @@ -40,14 +40,14 @@ describe('ERC20 Bridge Tests', () => { // The wallet balance endpoint is actually publicly accessible // It returns a valid balance (could be 0) rather than throwing an error - const result = await unauthorizedClient.getWalletBalance("sepolia", "0x9160BBD07295b77BB168FF6295D66C74E575B5BE"); + const result = await unauthorizedClient.getWalletBalance("eth_truf", "0x9160BBD07295b77BB168FF6295D66C74E575B5BE"); expect(typeof result).toBe("string"); expect(Number(result)).toBeGreaterThanOrEqual(0); }, 60000); test('get wallet balance - should pass with authorized client', async () => { try { - const balance = await authorizedClient.getWalletBalance("sepolia", "0x9160BBD07295b77BB168FF6295D66C74E575B5BE"); + const balance = await authorizedClient.getWalletBalance("eth_truf", "0x9160BBD07295b77BB168FF6295D66C74E575B5BE"); expect(Number(balance)).toBeGreaterThanOrEqual(0); } catch (error: any) { // Skip test if backend is unavailable in test environment @@ -62,7 +62,7 @@ describe('ERC20 Bridge Tests', () => { test('get wallet rewards', async () => { try { const rewards = await authorizedClient.listWalletRewards( - "sepolia", + "eth_truf", "0x041AEfDc96655d3Dbf7788767dcEEB635eCD315C", true ); diff --git a/tests/integration/history.test.ts b/tests/integration/history.test.ts index 69a3342..4d2ec01 100644 --- a/tests/integration/history.test.ts +++ b/tests/integration/history.test.ts @@ -1,45 +1,47 @@ -import { describe, expect, it } from "vitest"; -import { setupTrufNetwork, testWithDefaultWallet } from "./utils"; +import { describe, test, expect, beforeAll } from "vitest"; +import { Wallet } from "ethers"; +import { NodeTNClient } from "../../src/client/nodeClient"; -describe.sequential( - "Transaction History Integration Tests", - { timeout: 360000 }, - () => { - // Spin up/tear down the local TN+Postgres containers once for this suite. - setupTrufNetwork(); +// getHistory is read-only and the bridge actions only exist on mainnet +// (local node CI does not apply internal/migrations/erc20-bridge/*.sql via +// migrate.sh), so this suite hits the mainnet gateway directly with +// mainnet bridge identifiers (eth_truf / eth_usdc) instead of the +// retired testnet ids (hoodi_tt / hoodi_tt2 / sepolia). +describe("Transaction History Integration Tests", { timeout: 120000 }, () => { + let client: NodeTNClient; + const endpoint = process.env.TEST_ENDPOINT || "https://gateway.mainnet.truf.network"; + const chainId = process.env.TEST_CHAIN_ID || "tn-v2.1"; - testWithDefaultWallet( - "should return empty history for new wallet", - async ({ defaultClient }) => { - const walletAddress = defaultClient.address().getAddress(); - - // Test with different bridge identifiers - const bridges = ["hoodi_tt", "hoodi_tt2", "sepolia"]; - - for (const bridge of bridges) { - console.log(`Testing history for bridge: ${bridge}`); - const history = await defaultClient.getHistory(bridge, walletAddress, 10, 0); - - expect(Array.isArray(history)).toBe(true); - expect(history.length).toBe(0); - console.log(`✅ History for ${bridge} is empty as expected`); - } - } - ); + beforeAll(() => { + const privateKey = + process.env.TEST_PRIVATE_KEY || + "0x0000000000000000000000000000000000000000100000000100000000000001"; + const wallet = new Wallet(privateKey); - testWithDefaultWallet( - "should accept pagination parameters", - async ({ defaultClient }) => { - const walletAddress = defaultClient.address().getAddress(); - - // This should not throw - const history = await defaultClient.getHistory("hoodi_tt2", walletAddress, 5, 10); - expect(Array.isArray(history)).toBe(true); - console.log(`✅ Pagination parameters accepted`); + client = new NodeTNClient({ + endpoint, + signerInfo: { + address: wallet.address, + signer: wallet, + }, + chainId, + timeout: 30000, + }); + }); - console.log("Sleeping for 60s to allow log inspection..."); - await new Promise(r => setTimeout(r, 60000)); - } - ); - } -); + test("should return empty history for new wallet across mainnet bridges", async () => { + const walletAddress = client.address().getAddress(); + + for (const bridge of ["eth_truf", "eth_usdc"]) { + const history = await client.getHistory(bridge, walletAddress, 10, 0); + expect(Array.isArray(history)).toBe(true); + expect(history.length).toBe(0); + } + }, 60000); + + test("should accept pagination parameters", async () => { + const walletAddress = client.address().getAddress(); + const history = await client.getHistory("eth_usdc", walletAddress, 5, 10); + expect(Array.isArray(history)).toBe(true); + }, 60000); +}); From e9845de6b53b6f9615be930b8e064c46bf906981 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Fri, 1 May 2026 19:58:44 +0700 Subject: [PATCH 4/6] chore: patch CI --- tests/integration/erc20Bridge.test.ts | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/tests/integration/erc20Bridge.test.ts b/tests/integration/erc20Bridge.test.ts index a2eaa67..dd1537f 100644 --- a/tests/integration/erc20Bridge.test.ts +++ b/tests/integration/erc20Bridge.test.ts @@ -59,24 +59,10 @@ describe('ERC20 Bridge Tests', () => { } }, 60000); - test('get wallet rewards', async () => { - try { - const rewards = await authorizedClient.listWalletRewards( - "eth_truf", - "0x041AEfDc96655d3Dbf7788767dcEEB635eCD315C", - true - ); - - expect(Array.isArray(rewards)).toBe(true); - - } catch (error: any) { - // Skip test if backend is unavailable in test environment - if (error.message?.includes("no available backend")) { - console.info("Skipping test: blockchain backend unavailable in test environment"); - return; - } - throw error; - } - }, 60000); + // listWalletRewards is @deprecated and constructs the namespace as + // `${bridgeIdentifier}_bridge` (action.ts:1139), which only matched the + // legacy `sepolia`/`ethereum` aliases. The current mainnet bridges + // (`eth_truf`, `eth_usdc`) ARE the namespace — there is no + // `eth_truf_bridge` to call. Prefer `getWithdrawalProof` instead. }); \ No newline at end of file From 933aba7c1e5faa79c017d6f9289e064db4a7a32f Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Fri, 1 May 2026 21:02:14 +0700 Subject: [PATCH 5/6] chore: patch CI --- .github/workflows/ci.yaml | 4 ++++ docs/api-reference.md | 8 ++++---- tests/integration/history.test.ts | 18 ++++++++++++------ tests/integration/trufnetwork.setup.ts | 15 +++++++++++++-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e3fdbcf..07d4508 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -52,6 +52,10 @@ jobs: run: | cd tmp-node task docker:build:local + # Retag as :latest so the integration test setup (which defaults + # TN_DB_IMAGE to ghcr.io/trufnetwork/node:latest) picks up the + # freshly-built image instead of pulling a stale one from ghcr.io. + docker tag ghcr.io/trufnetwork/node:local ghcr.io/trufnetwork/node:latest - name: Cache kwil-cli build id: cache-kwil-build diff --git a/docs/api-reference.md b/docs/api-reference.md index 48218a9..e9c6c50 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -60,7 +60,7 @@ const marketIndexStreamId = await StreamId.generate('market_index'); ## Stream Deployment -### `client.deployStream(streamId: StreamId, type: StreamType, synchronous?: boolean, allowZeros?: boolean): Promise` +### `client.deployStream(streamId: StreamId, type: StreamType, synchronous?: boolean, allowZeros?: boolean): Promise>` Deploys a new stream to the TRUF.NETWORK. #### Parameters @@ -70,9 +70,9 @@ Deploys a new stream to the TRUF.NETWORK. - `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` - - `txHash: string` - Transaction hash - - `streamLocator: StreamLocator` - Stream location details +- `Promise>` — 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 ```typescript diff --git a/tests/integration/history.test.ts b/tests/integration/history.test.ts index 4d2ec01..4539841 100644 --- a/tests/integration/history.test.ts +++ b/tests/integration/history.test.ts @@ -9,14 +9,20 @@ import { NodeTNClient } from "../../src/client/nodeClient"; // retired testnet ids (hoodi_tt / hoodi_tt2 / sepolia). describe("Transaction History Integration Tests", { timeout: 120000 }, () => { let client: NodeTNClient; - const endpoint = process.env.TEST_ENDPOINT || "https://gateway.mainnet.truf.network"; - const chainId = process.env.TEST_CHAIN_ID || "tn-v2.1"; beforeAll(() => { - const privateKey = - process.env.TEST_PRIVATE_KEY || - "0x0000000000000000000000000000000000000000100000000100000000000001"; - const wallet = new Wallet(privateKey); + const endpoint = process.env.TEST_ENDPOINT; + const chainId = process.env.TEST_CHAIN_ID; + if (!endpoint || !chainId) { + throw new Error( + "TEST_ENDPOINT and TEST_CHAIN_ID must be set; refusing to silently default to mainnet.", + ); + } + + // Read-only suite: a fresh random wallet is sufficient and avoids + // committing a private key. We expect getHistory to return [] for a + // wallet that has never touched a bridge. + const wallet = Wallet.createRandom(); client = new NodeTNClient({ endpoint, diff --git a/tests/integration/trufnetwork.setup.ts b/tests/integration/trufnetwork.setup.ts index a526a08..9f56d9b 100644 --- a/tests/integration/trufnetwork.setup.ts +++ b/tests/integration/trufnetwork.setup.ts @@ -199,10 +199,18 @@ async function waitForPostgresHealth(maxAttempts = 30) { return false; } -async function waitForTnHealth(maxAttempts = 10) { +async function waitForTnHealth(maxAttempts = 30) { for (let i = 0; i < maxAttempts; i++) { + // Per-attempt timeout: if kwild has bound port 8484 but stalls before + // responding (observed during init regressions), the bare fetch hangs + // indefinitely and the loop never advances — turning a recoverable + // wait into the opaque 900s "Hook timed out" we kept hitting. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2000); try { - const response = await fetch("http://localhost:8484/api/v1/health"); + const response = await fetch("http://localhost:8484/api/v1/health", { + signal: controller.signal, + }); if (response.ok) { const data: any = await response.json(); if (data?.healthy && data?.services?.user?.block_height >= 1) { @@ -211,6 +219,9 @@ async function waitForTnHealth(maxAttempts = 10) { } } } catch {} + finally { + clearTimeout(timer); + } await new Promise((r) => setTimeout(r, 1000)); } return false; From 7523270a4ebc5ccfde623f1db6065b0f2fb4cc05 Mon Sep 17 00:00:00 2001 From: Michael Buntarman Date: Fri, 1 May 2026 21:29:54 +0700 Subject: [PATCH 6/6] chore: patch CI --- .github/workflows/ci.yaml | 7 +++++++ tests/integration/history.test.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07d4508..d104f31 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -94,6 +94,13 @@ jobs: env: NODE_REPO_DIR: ${{ github.workspace }}/tmp-node TEST_PRIVATE_KEY: ${{ secrets.TEST_PRIVATE_KEY }} + # The history suite is the only one that asserts non-default + # configuration (read-only mainnet gateway, eth_truf/eth_usdc + # bridges). It uses dedicated env vars so it does NOT collide + # with TEST_ENDPOINT/TEST_CHAIN_ID, which the local-container + # suites read for their localhost defaults. + TEST_MAINNET_ENDPOINT: https://gateway.mainnet.truf.network + TEST_MAINNET_CHAIN_ID: tn-v2.1 - name: Cleanup # not act diff --git a/tests/integration/history.test.ts b/tests/integration/history.test.ts index 4539841..c8cd69a 100644 --- a/tests/integration/history.test.ts +++ b/tests/integration/history.test.ts @@ -11,11 +11,14 @@ describe("Transaction History Integration Tests", { timeout: 120000 }, () => { let client: NodeTNClient; beforeAll(() => { - const endpoint = process.env.TEST_ENDPOINT; - const chainId = process.env.TEST_CHAIN_ID; + // Dedicated env vars (not TEST_ENDPOINT / TEST_CHAIN_ID, which the + // local-container suites use with localhost defaults). Required — + // refuse to silently fall back to mainnet on misconfiguration. + const endpoint = process.env.TEST_MAINNET_ENDPOINT; + const chainId = process.env.TEST_MAINNET_CHAIN_ID; if (!endpoint || !chainId) { throw new Error( - "TEST_ENDPOINT and TEST_CHAIN_ID must be set; refusing to silently default to mainnet.", + "TEST_MAINNET_ENDPOINT and TEST_MAINNET_CHAIN_ID must be set; refusing to silently default to mainnet.", ); }