Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,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
Expand Down
31 changes: 27 additions & 4 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,26 +60,49 @@ const marketIndexStreamId = await StreamId.generate('market_index');

## Stream Deployment

### `client.deployStream(streamId: StreamId, type: StreamType): Promise<DeploymentResult>`
### `client.deployStream(streamId: StreamId, type: StreamType, synchronous?: boolean, allowZeros?: boolean): Promise<Types.GenericResponse<Types.TxReceipt>>`
Deploys a new stream to the TRUF.NETWORK.

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

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

#### Example
```typescript
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<boolean>`
Returns the current `allow_zeros` setting for the stream. Returns `false` when the stream has no explicit metadata row, matching the implicit default applied at insert time.

## Stream Destruction

### `client.destroyStream(streamLocator: StreamLocator): Promise<DestructionResult>`
Expand Down
2 changes: 2 additions & 0 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,13 @@ export abstract class BaseTNClient<T extends EnvironmentType> {
streamId: StreamId,
streamType: StreamType,
synchronous?: boolean,
allowZeros?: boolean,
): Promise<Types.GenericResponse<Types.TxReceipt>> {
return await deployStream({
streamId,
streamType,
synchronous,
allowZeros,
kwilClient: this.getKwilClient(),
kwilSigner: this.getKwilSigner(),
});
Expand Down
83 changes: 81 additions & 2 deletions src/contracts-api/action.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
Expand All @@ -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();
});
});
});
54 changes: 53 additions & 1 deletion src/contracts-api/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -490,11 +500,17 @@ export class Action {
};
}

protected async setMetadata<K extends MetadataKey>(
protected async setMetadata<K extends MutableMetadataKey>(
stream: StreamLocator,
key: K,
value: MetadataValueTypeForKey<K>,
): Promise<Types.GenericResponse<Types.TxReceipt>> {
// 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(),
Expand Down Expand Up @@ -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<Types.GenericResponse<Types.TxReceipt>> {
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<boolean> {
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
*/
Expand Down
21 changes: 21 additions & 0 deletions src/contracts-api/contractValues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MetadataKey, ReservedMetadataKey>;

export function isReservedMetadataKey(key: MetadataKey): key is ReservedMetadataKey {
return (RESERVED_METADATA_KEYS as readonly MetadataKey[]).includes(key);
}

export const MetadataType = {
Int: "int",
Bool: "bool",
Expand All @@ -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<MetadataKey, MetadataType>;

type MetadataValueMap = {
Expand Down
29 changes: 24 additions & 5 deletions src/contracts-api/deployStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export interface DeployStreamInput {
kwilClient: Types.Kwil<any>;
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 {
Expand All @@ -23,13 +29,26 @@ export async function deployStream(
input: DeployStreamInput,
): Promise<Types.GenericResponse<Types.TxReceipt>> {
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()}`
},
Expand All @@ -41,4 +60,4 @@ export async function deployStream(
} catch (error) {
throw new Error(`Failed to deploy stream: ${error}`);
}
}
}
Loading
Loading