diff --git a/.gitignore b/.gitignore index f3f7a62..cdd11c9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ out *.env CLAUDE.md +GEMINI.md # Orderbook example temporary files examples/orderbook/.query_id \ No newline at end of file diff --git a/docs/api-reference.md b/docs/api-reference.md index f038d4a..0519f27 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1066,6 +1066,44 @@ await tx.wait(); console.log(`Withdrawal claimed! Tx: ${tx.hash}`); ``` +### `client.getHistory(bridgeIdentifier: string, walletAddress: string, limit?: number, offset?: number): Promise` +Retrieves the transaction history for a wallet on a specific bridge. This method is provided by the base action handler (`client.loadAction()`) and also exposed directly on the client instance (`client.getHistory(...)`) for convenience. + +#### Parameters +- `bridgeIdentifier: string` - The unique identifier of the bridge (e.g., "hoodi_tt2") +- `walletAddress: string` - The wallet address to query +- `limit?: number` - Max number of records to return (optional, default 20) +- `offset?: number` - Number of records to skip (optional, default 0) + +#### Returns +- `Promise` - Array of history records + +#### Example +```typescript +const history = await client.getHistory("hoodi_tt2", "0x...", 10, 0); + +for (const rec of history) { + console.log(`${rec.type} - Amount: ${rec.amount} - Status: ${rec.status}`); +} +``` + +#### `BridgeHistory` Type + +```typescript +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` Lists wallet rewards for a specific bridge instance. This is a low-level method that directly accesses the bridge extension namespace. diff --git a/examples/history_example/index.ts b/examples/history_example/index.ts new file mode 100644 index 0000000..6dd2cd7 --- /dev/null +++ b/examples/history_example/index.ts @@ -0,0 +1,118 @@ +import { NodeTNClient } from "../../src/index.node"; +import { Wallet } from "ethers"; +import dotenv from "dotenv"; + +dotenv.config(); + +async function main() { + const privateKey = process.env.TN_PRIVATE_KEY || "0000000000000000000000000000000000000000000000000000000000000001"; + const endpoint = process.env.TN_GATEWAY_URL || "https://gateway.testnet.truf.network"; + const wallet = new Wallet(privateKey); + + const client = new NodeTNClient({ + endpoint: endpoint, + signerInfo: { + address: wallet.address, + signer: wallet, + }, + chainId: "testnet-v1", + timeout: 30000, + }); + + console.log("šŸ”„ Transaction History Demo (JS)"); + console.log("==============================="); + console.log(`Endpoint: ${endpoint}`); + console.log(`Wallet: ${wallet.address}\n`); + + const bridgeId = "hoodi_tt2"; + const targetWallet = "0xc11Ff6d3cC60823EcDCAB1089F1A4336053851EF"; + const limit = 10; + const offset = 0; + + console.log(`šŸ“‹ Fetching history for bridge '${bridgeId}'...`); + console.log(` Wallet: ${targetWallet}`); + console.log(` Limit: ${limit}`); + console.log(` Offset: ${offset}`); + console.log("-".repeat(60)); + + try { + const history = await client.getHistory(bridgeId, targetWallet, limit, offset); + + if (history.length === 0) { + console.log("No history records found."); + return; + } + + // Helper to format/shorten + const formatShort = (val: string | null): string => { + if (!val) return "null"; + + let s = val; + // Check if it's Base64 (Kwil default) or already Hex + if (!s.startsWith("0x")) { + try { + const buffer = Buffer.from(s, 'base64'); + // Check if it looks like a hash/address (20 or 32 bytes) + if (buffer.length === 20 || buffer.length === 32) { + s = "0x" + buffer.toString('hex'); + } + } catch (e) { + // Not base64, keep as is + } + } + + if (s.length > 12) { + return s.substring(0, 10) + "..."; + } + return s; + }; + + // Print Header + console.log( + "TYPE".padEnd(12) + + "AMOUNT".padEnd(22) + + "FROM".padEnd(14) + + "TO".padEnd(14) + + "INT TX".padEnd(14) + + "EXT TX".padEnd(14) + + "STATUS".padEnd(12) + + "BLOCK".padEnd(8) + + "TIMESTAMP" + ); + console.log( + "-".repeat(12) + " " + + "-".repeat(22) + " " + + "-".repeat(14) + " " + + "-".repeat(14) + " " + + "-".repeat(14) + " " + + "-".repeat(14) + " " + + "-".repeat(12) + " " + + "-".repeat(8) + " " + + "-".repeat(20) + ); + + for (const rec of history) { + const date = new Date(rec.block_timestamp * 1000).toISOString(); + + console.log( + rec.type.padEnd(12) + + rec.amount.toString().padEnd(22) + + formatShort(rec.from_address).padEnd(14) + + formatShort(rec.to_address).padEnd(14) + + formatShort(rec.internal_tx_hash).padEnd(14) + + formatShort(rec.external_tx_hash).padEnd(14) + + rec.status.padEnd(12) + + rec.block_height.toString().padEnd(8) + + date + ); + } + + console.log(`\nāœ… Successfully retrieved ${history.length} records.`); + console.log("\nNote: 'completed' means credited (deposits) or ready to claim (withdrawals). 'claimed' means withdrawn on Ethereum."); + + } catch (error) { + console.error("āŒ Failed to fetch history:", error); + } +} + +main(); diff --git a/src/client/client.ts b/src/client/client.ts index f086cd3..75963c2 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -5,7 +5,7 @@ import { deleteStream } from "../contracts-api/deleteStream"; import { PrimitiveAction } from "../contracts-api/primitiveAction"; import { Action, ListMetadataByHeightParams, MetadataQueryResult } from "../contracts-api/action"; import { StreamType } from "../contracts-api/contractValues"; -import { WithdrawalProof } from "../types/bridge"; +import { WithdrawalProof, BridgeHistory } from "../types/bridge"; import { StreamLocator, TNStream } from "../types/stream"; import { EthereumAddress } from "../util/EthereumAddress"; import { StreamId } from "../util/StreamId"; @@ -388,6 +388,26 @@ export abstract class BaseTNClient { return action.getWithdrawalProof(bridgeIdentifier, walletAddress); } + /** + * Retrieves the transaction history for a wallet on a specific bridge. + * + * @param bridgeIdentifier - The name of the bridge instance (e.g., "hoodi_tt", "sepolia_bridge") + * @param walletAddress - The wallet address to query + * @param limit - Max number of records to return (default 20) + * @param offset - Number of records to skip (default 0) + * @returns Promise resolving to an array of BridgeHistory records + * + * @example + * ```typescript + * const history = await client.getHistory("sepolia_bridge", "0x123..."); + * console.log(history); + * ``` + */ + async getHistory(bridgeIdentifier: string, walletAddress: string, limit: number = 20, offset: number = 0): Promise { + const action = this.loadAction(); + return action.getHistory(bridgeIdentifier, walletAddress, limit, offset); + } + /** * Gets taxonomies for specific streams in batch. * High-level wrapper for ComposedAction.getTaxonomiesForStreams() diff --git a/src/contracts-api/action.test.ts b/src/contracts-api/action.test.ts new file mode 100644 index 0000000..484c7c1 --- /dev/null +++ b/src/contracts-api/action.test.ts @@ -0,0 +1,80 @@ +import { Action } from "./action"; +import { BridgeHistory } from "../types/bridge"; +import { KwilSigner, NodeKwil } from "@trufnetwork/kwil-js"; +import { Either } from "monads-io"; +import { vi, describe, it, expect } from "vitest"; + +describe("Action", () => { + // Mock dependencies + const mockKwil = { + call: vi.fn(), + } as unknown as NodeKwil; + + const mockSigner = { + signatureType: "secp256k1_ep", + } as unknown as KwilSigner; + + it("should call get_history with correct parameters", async () => { + const action = new Action(mockKwil, mockSigner); + + // Spy on the 'call' method of the action instance + const callSpy = vi.spyOn(action as any, "call"); + + const mockHistory: BridgeHistory[] = [{ + type: "deposit", + amount: "100", + from_address: null, + to_address: "0x123", + internal_tx_hash: null, + external_tx_hash: "0xabc", + status: "completed", + block_height: 10, + block_timestamp: 1000, + external_block_height: 5 + }]; + + callSpy.mockResolvedValue(Either.right(mockHistory)); + + const result = await action.getHistory("sepolia_bridge", "0x123", 10, 5); + + expect(callSpy).toHaveBeenCalledWith( + "sepolia_bridge_get_history", + { + $wallet_address: "0x123", + $limit: 10, + $offset: 5 + } + ); + + expect(result).toEqual(mockHistory); + }); + + it("should handle null results from get_history", async () => { + const action = new Action(mockKwil, mockSigner); + const callSpy = vi.spyOn(action as any, "call"); + + callSpy.mockResolvedValue(Either.right(null)); + + const result = await action.getHistory("bridge", "0x123"); + + expect(result).toEqual([]); + }); + + 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"); + + expect(callSpy).toHaveBeenCalledWith( + "bridge_get_history", + { + $wallet_address: "0x123", + $limit: 20, + $offset: 0 + } + ); + }); +}); diff --git a/src/contracts-api/action.ts b/src/contracts-api/action.ts index 9526d31..9048249 100644 --- a/src/contracts-api/action.ts +++ b/src/contracts-api/action.ts @@ -1,6 +1,6 @@ import {KwilSigner, NodeKwil, WebKwil, Types} from "@trufnetwork/kwil-js"; import { Either } from "monads-io"; -import { WithdrawalProof } from "../types/bridge"; +import { WithdrawalProof, BridgeHistory } from "../types/bridge"; import { DateString } from "../types/other"; import { StreamLocator } from "../types/stream"; import { CacheAwareResponse, GetRecordOptions, GetIndexOptions, GetIndexChangeOptions, GetFirstRecordOptions } from "../types/cache"; @@ -1147,4 +1147,35 @@ export class Action { }) .throw(); } + + /** + * Retrieves the unified transaction history for a wallet on a specific bridge. + * + * @param bridgeIdentifier - The name of the bridge instance (e.g., "hoodi_tt", "sepolia_bridge") + * @param walletAddress - The wallet address to query + * @param limit - Max number of records to return (default 20) + * @param offset - Number of records to skip (default 0) + * @returns Array of BridgeHistory records + */ + public async getHistory( + bridgeIdentifier: string, + walletAddress: string, + limit: number = 20, + offset: number = 0 + ): Promise { + const result = await this.call( + `${bridgeIdentifier}_get_history`, + { + $wallet_address: walletAddress, + $limit: limit, + $offset: offset + } + ); + + return result + .mapRight((rows) => { + return rows || []; + }) + .throw(); + } } diff --git a/src/types/bridge.ts b/src/types/bridge.ts index bb98c9b..d539e71 100644 --- a/src/types/bridge.ts +++ b/src/types/bridge.ts @@ -76,3 +76,58 @@ export interface WithdrawalProof { */ signatures: string[]; } + +/** + * Transaction history record from the bridge extension. + */ +export interface BridgeHistory { + /** + * The type of transaction ('deposit', 'withdrawal', 'transfer') + */ + type: string; + + /** + * The amount transferred/deposited/withdrawn + */ + amount: string; + + /** + * The sender address (null for deposits) + */ + from_address: string | null; + + /** + * The recipient address + */ + to_address: string; + + /** + * The Kwil transaction hash (null for deposits, if not linked) + */ + internal_tx_hash: string | null; + + /** + * The external transaction hash (null for internal transfers) + */ + external_tx_hash: string | null; + + /** + * The status of the transaction ('completed', 'pending_epoch', 'claimed') + */ + status: string; + + /** + * The Kwil block height + */ + block_height: number; + + /** + * The timestamp of the block (Unix timestamp) + */ + block_timestamp: number; + + /** + * The external block height (if applicable) + */ + external_block_height: number | null; +} diff --git a/vitest.config.ts b/vitest.config.ts index 7cdcea5..bc4879f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ singleFork: true, }, }, - hookTimeout: 600000, // 600 seconds for setup hooks (Docker containers in CI) + hookTimeout: 900000, // 900 seconds (15 minutes) for setup hooks (Docker containers in CI) }, ssr: { noExternal: ['@trufnetwork/kwil-js'],