Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
12 changes: 12 additions & 0 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { getLastTransactions } from "./getLastTransactions";
import { RoleManagement } from "../contracts-api/roleManagement";
import { AttestationAction } from "../contracts-api/attestationAction";
import { TransactionAction } from "../contracts-api/transactionAction";
import { OwnerIdentifier } from "../types/role";

export interface SignerInfo {
Expand Down Expand Up @@ -66,7 +67,7 @@
case 200:
if (receipt.data?.tx_result?.log !== undefined && receipt.data?.tx_result?.log.includes("ERROR")) {
reject(
new Error(

Check failure on line 70 in src/client/client.ts

View workflow job for this annotation

GitHub Actions / test

tests/integration/attestation.test.ts > Attestation Integration Tests > should list attestations with default params

Error: Transaction failed: status 200 : log message ERROR: type error: action "request_attestation" expected argument 6 to be of type numeric(78,0), but got int8 ❯ Timeout._onTimeout src/client/client.ts:70:19

Check failure on line 70 in src/client/client.ts

View workflow job for this annotation

GitHub Actions / test

tests/integration/attestation.test.ts > Attestation Integration Tests > should retrieve signed attestation after waiting

Error: Transaction failed: status 200 : log message ERROR: type error: action "request_attestation" expected argument 6 to be of type numeric(78,0), but got int8 ❯ Timeout._onTimeout src/client/client.ts:70:19

Check failure on line 70 in src/client/client.ts

View workflow job for this annotation

GitHub Actions / test

tests/integration/attestation.test.ts > Attestation Integration Tests > should request attestation successfully

Error: Transaction failed: status 200 : log message ERROR: type error: action "request_attestation" expected argument 6 to be of type numeric(78,0), but got int8 ❯ Timeout._onTimeout src/client/client.ts:70:19
`Transaction failed: status ${receipt.status} : log message ${receipt.data?.tx_result.log}`,
))
} else {
Expand Down Expand Up @@ -201,6 +202,17 @@
);
}

/**
* Loads the transaction action API, permitting transaction ledger queries.
* @returns An instance of TransactionAction.
*/
loadTransactionAction(): TransactionAction {
return new TransactionAction(
this.getKwilClient() as WebKwil | NodeKwil,
this.getKwilSigner(),
);
}

/**
* Creates a new stream locator.
* @param streamId - The ID of the stream.
Expand Down
159 changes: 159 additions & 0 deletions src/contracts-api/transactionAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { KwilSigner, NodeKwil, WebKwil, Types } from "@trufnetwork/kwil-js";
import { TransactionEvent, FeeDistribution, GetTransactionEventInput } from "../types/transaction";

/**
* Database row structure returned from get_transaction_event action
*/
interface TransactionEventRow {
tx_id: string;
block_height: string | number;
method: string;
caller: string;
fee_amount: string | number;
fee_recipient?: string | null;
metadata?: string | null;
fee_distributions: string;
}

/**
* TransactionAction provides methods for querying transaction ledger data
*/
export class TransactionAction {
protected kwilClient: WebKwil | NodeKwil;
protected kwilSigner: KwilSigner;

constructor(kwilClient: WebKwil | NodeKwil, kwilSigner: KwilSigner) {
this.kwilClient = kwilClient;
this.kwilSigner = kwilSigner;
}

/**
* Fetches detailed transaction information by transaction hash
*
* @param input Transaction query input containing tx hash
* @returns Promise resolving to transaction event with fee details
* @throws Error if transaction not found or query fails
*
* @example
* ```typescript
* const txAction = client.loadTransactionAction();
* const txEvent = await txAction.getTransactionEvent({
* txId: "0xabcdef123456..."
* });
* console.log(`Method: ${txEvent.method}, Fee: ${txEvent.feeAmount} TRUF`);
* ```
*/
async getTransactionEvent(input: GetTransactionEventInput): Promise<TransactionEvent> {
if (!input.txId || input.txId.trim() === "") {
throw new Error("tx_id is required");
}

const result = await this.kwilClient.call(
{
namespace: "main",
name: "get_transaction_event",
inputs: {
$tx_id: input.txId,
},
},
this.kwilSigner
);

if (result.status !== 200) {
throw new Error(`Failed to get transaction event: HTTP ${result.status}`);
}

if (!result.data?.result || result.data.result.length === 0) {
throw new Error(`Transaction not found: ${input.txId}`);
}

const row = result.data.result[0] as TransactionEventRow;

// Validate required fields
if (!row.method || typeof row.method !== 'string' || row.method.trim() === '') {
throw new Error(`Missing or invalid method field (tx: ${row.tx_id})`);
}

if (!row.caller || typeof row.caller !== 'string' || row.caller.trim() === '') {
throw new Error(`Missing or invalid caller field (tx: ${row.tx_id})`);
}

if (row.fee_amount === null || row.fee_amount === undefined) {
throw new Error(`Missing fee_amount field (tx: ${row.tx_id})`);
}

// Validate fee_amount is numeric (can be string or number)
const feeAmount = typeof row.fee_amount === 'string' ? row.fee_amount : String(row.fee_amount);
const feeAmountNum = Number(feeAmount);
if (isNaN(feeAmountNum) || !Number.isFinite(feeAmountNum)) {
throw new Error(`Invalid fee_amount (not numeric): ${row.fee_amount} (tx: ${row.tx_id})`);
}
if (feeAmountNum < 0) {
throw new Error(`Invalid fee_amount (negative): ${row.fee_amount} (tx: ${row.tx_id})`);
}

// Parse fee_distributions string: "recipient1:amount1,recipient2:amount2"
const feeDistributions: FeeDistribution[] = [];
if (row.fee_distributions && row.fee_distributions !== "") {
const parts = row.fee_distributions.split(",");
for (const part of parts) {
const trimmedPart = part.trim();
if (trimmedPart) {
// Split only on first colon to handle addresses with colons
const colonIndex = trimmedPart.indexOf(":");
if (colonIndex === -1) {
throw new Error(`Invalid fee distribution format (missing colon): ${trimmedPart} (tx: ${row.tx_id})`);
}

const recipient = trimmedPart.substring(0, colonIndex).trim();
const amount = trimmedPart.substring(colonIndex + 1).trim();

if (!recipient || !amount) {
throw new Error(`Invalid fee distribution entry (empty recipient or amount): ${trimmedPart} (tx: ${row.tx_id})`);
}

// Validate amount is numeric and non-negative
const amt = Number(amount);
if (isNaN(amt) || !Number.isFinite(amt)) {
throw new Error(`Invalid fee distribution amount (not numeric): ${amount} (tx: ${row.tx_id})`);
}
if (amt < 0) {
throw new Error(`Invalid fee distribution amount (negative): ${amount} (tx: ${row.tx_id})`);
}

feeDistributions.push({ recipient, amount });
}
}
}

// Validate block height
const blockHeight = typeof row.block_height === 'number'
? row.block_height
: parseInt(row.block_height, 10);
if (!Number.isFinite(blockHeight) || blockHeight < 0) {
throw new Error(`Invalid block height: ${row.block_height} (tx: ${row.tx_id})`);
}

return {
txId: row.tx_id,
blockHeight,
method: row.method,
caller: row.caller,
feeAmount,
feeRecipient: row.fee_recipient || undefined,
metadata: row.metadata || undefined,
feeDistributions,
};
}

/**
* Creates a TransactionAction instance from an existing client and signer
*
* @param kwilClient The Kwil client (Web or Node)
* @param kwilSigner The Kwil signer for authentication
* @returns A new TransactionAction instance
*/
static fromClient(kwilClient: WebKwil | NodeKwil, kwilSigner: KwilSigner): TransactionAction {
return new TransactionAction(kwilClient, kwilSigner);
}
}
62 changes: 62 additions & 0 deletions src/types/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,65 @@ export interface LastTransaction {
/** Millisecond timestamp from the block header */
stampMs: number;
}

/**
* Represents a single fee distribution
*/
export interface FeeDistribution {
/** Recipient Ethereum address */
recipient: string;
/** Amount as string (handles large numbers) */
amount: string;
}

/**
* Represents a single transaction event from the ledger
*
* Fee Fields Relationship:
* - feeAmount: Total fee charged for the transaction (as string to handle large numbers)
* - feeRecipient: Primary fee recipient address (if single recipient). May be undefined for:
* 1. Transactions from fee-exempt wallets (system:network_writer role)
* 2. Transactions with multiple fee distributions (use feeDistributions instead)
* - feeDistributions: Array of fee distributions to multiple recipients
* - Aggregated using string_agg() in get_transaction_event query
* - Parsed from "recipient1:amount1,recipient2:amount2" format
* - Sum of amounts in feeDistributions equals feeAmount (when present)
* - Empty array when there are no distributions or single recipient (use feeRecipient)
*/
export interface TransactionEvent {
/** Transaction hash (0x-prefixed) */
txId: string;
/** Block height when transaction was included */
blockHeight: number;
/** Method name (e.g., "deployStream", "insertRecords") */
method: string;
/** Ethereum address of caller (lowercase, 0x-prefixed) */
caller: string;
/**
* Total fee amount as string (handles large numbers with 18 decimals)
* Will be "0" for fee-exempt wallets (system:network_writer role)
*/
feeAmount: string;
/**
* Primary fee recipient address (lowercase, 0x-prefixed)
* Undefined when:
* - Wallet is fee-exempt (feeAmount === "0")
* - Fee has multiple distributions (check feeDistributions)
*/
feeRecipient?: string;
/** Optional metadata JSON (nullable) */
metadata?: string;
/**
* Array of fee distributions to multiple recipients
* Aggregated from transaction_event_distributions table using string_agg()
*/
feeDistributions: FeeDistribution[];
}

/**
* Input for getting transaction event
*/
export interface GetTransactionEventInput {
/** Transaction hash (with or without 0x prefix) */
txId: string;
}
Loading
Loading