-
Notifications
You must be signed in to change notification settings - Fork 42
feat: publish @clevercon/vault-sdk — reusable typed SDK for CleverVault #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| # @clevercon/vault-sdk | ||
|
|
||
| Reusable typed SDK for the CleverVault Soroban smart contract. | ||
|
|
||
| ## Features | ||
|
|
||
| - **Typed methods** for every contract entrypoint (deposit, withdraw, register/update orchestrator, create/complete/cancel task, release payment, all `get_*` views) | ||
| - **Structured errors** — `VaultContractError` with `code`, `codeName`, and `known` flag; unknown/newer codes are preserved, never swallowed | ||
| - **Event subscription** — helper over Soroban RPC `getEvents` with typed payloads and cursor handling | ||
| - **Mock mode** — dependency-free deterministic responses for local development and testing | ||
| - **Network config** — mainnet/testnet selection, contract ID, RPC URL | ||
|
|
||
| ## Quick Start | ||
|
|
||
| ```ts | ||
| import { VaultClient } from '@clevercon/vault-sdk'; | ||
|
|
||
| const vault = new VaultClient({ | ||
| contractId: process.env.AGENT_VAULT_CONTRACT_ID!, | ||
| rpcUrl: process.env.STELLAR_RPC_URL, | ||
| network: 'testnet', | ||
| usdcSac: process.env.USDC_SAC, | ||
| }); | ||
|
|
||
| // Read-only views | ||
| const balance = await vault.getBalance(userAddress); | ||
| const account = await vault.getAccount(userAddress); | ||
| const task = await vault.getTask(taskId); | ||
| const status = await vault.getTaskStatus(taskId); | ||
|
|
||
| // State-changing calls (returns unsigned XDR for Freighter) | ||
| const xdr = await vault.buildDepositXdr(userAddress, 100); | ||
| // ... user signs in Freighter ... | ||
| await vault.submitSignedXdr(signedXdr); | ||
|
|
||
| // Server-side calls (signed with keypair) | ||
| const newTaskId = await vault.createTask(orchestratorKeypair, 50); | ||
| await vault.releasePayment(orchestratorKeypair, newTaskId, 1n, 10); | ||
| await vault.completeTask(orchestratorKeypair, newTaskId); | ||
| ``` | ||
|
|
||
| ## Mock Mode | ||
|
|
||
| For testing without RPC connectivity: | ||
|
|
||
| ```ts | ||
| import { createMockVaultClient } from '@clevercon/vault-sdk'; | ||
|
|
||
| const mock = createMockVaultClient(); | ||
|
|
||
| // Simulate operations | ||
| await mock.mockDeposit(userAddress, 100); | ||
| await mock.mockRegisterOrchestrator(userAddress, orchAddress, 'MyOrch'); | ||
| const taskId = await mock.mockCreateTask(orchAddress, 10); | ||
|
|
||
| // Read state | ||
| const balance = await mock.getBalance(userAddress); | ||
| const task = await mock.getTask(taskId); | ||
| ``` | ||
|
|
||
| ## Error Handling | ||
|
|
||
| ```ts | ||
| import { VaultContractError, VaultErrorCode } from '@clevercon/vault-sdk'; | ||
|
|
||
| try { | ||
| await vault.releasePayment(keypair, taskId, 1n, 100); | ||
| } catch (err) { | ||
| if (err instanceof VaultContractError) { | ||
| switch (err.code) { | ||
| case VaultErrorCode.ExceedsPlanCost: | ||
| // Handle over-budget | ||
| break; | ||
| case VaultErrorCode.TaskAlreadyCompleted: | ||
| // Handle completed task | ||
| break; | ||
| default: | ||
| if (!err.known) { | ||
| // Unknown error code — contract may have been upgraded | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Event Subscription | ||
|
|
||
| ```ts | ||
| import { subscribeEvents } from '@clevercon/vault-sdk'; | ||
|
|
||
| const sub = subscribeEvents( | ||
| { | ||
| rpcUrl: 'https://soroban-testnet.stellar.org', | ||
| contractId: 'C...', | ||
| topics: ['release', 'task_done'], | ||
| pollIntervalMs: 5000, | ||
| }, | ||
| (event) => { | ||
| switch (event.type) { | ||
| case 'release': | ||
| console.log(`Released ${event.payload.amount} for task ${event.payload.task_id}`); | ||
| break; | ||
| case 'task_done': | ||
| console.log(`Task ${event.payload.task_id} done, spent ${event.payload.spent}`); | ||
| break; | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| // Stop later | ||
| sub.stop(); | ||
| ``` | ||
|
|
||
| ## Architecture | ||
|
|
||
| ``` | ||
| packages/vault-sdk/ | ||
| ├── src/ | ||
| │ ├── index.ts # Barrel exports | ||
| │ ├── client.ts # VaultClient — wraps every contract entrypoint | ||
| │ ├── errors.ts # VaultError model (mirrors lib.rs) | ||
| │ ├── types.ts # TypeScript interfaces for contract data | ||
| │ ├── events.ts # Event subscription with cursor handling | ||
| │ ├── mock.ts # Dependency-free mock mode | ||
| │ ├── vault-errors.test.ts # Divergence test against lib.rs | ||
| │ └── client.test.ts # Unit tests for client and mock | ||
| ├── package.json | ||
| ├── tsconfig.json | ||
| └── README.md | ||
| ``` | ||
|
|
||
| ## Error Sync | ||
|
|
||
| The `vault-errors.test.ts` file reads `contracts/agent-vault/src/lib.rs` at test time and verifies that `VaultErrorCode` matches every variant and discriminant. If the contract adds a new error variant, the test fails until the SDK is updated. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| { | ||
| "name": "@clevercon/vault-sdk", | ||
| "version": "1.0.0", | ||
| "type": "module", | ||
| "main": "./src/index.ts", | ||
| "exports": { | ||
| ".": "./src/index.ts" | ||
| }, | ||
|
Comment on lines
+5
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- package manifest ---'
cat -n packages/vault-sdk/package.json
printf '%s\n' '--- nearby package files ---'
find packages/vault-sdk -maxdepth 2 -type f \
\( -name 'tsconfig*.json' -o -name 'package.json' -o -name 'pnpm-lock.yaml' -o -name 'README*' \) \
-print
printf '%s\n' '--- root workspace/package configuration ---'
find . -maxdepth 2 -type f \
\( -name 'package.json' -o -name 'pnpm-workspace.yaml' -o -name 'tsconfig*.json' \) \
-print | sort
printf '%s\n' '--- source entrypoint ---'
cat -n packages/vault-sdk/src/index.ts 2>/dev/null || trueRepository: clevercon-protocol/clevercon Length of output: 3109 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- package tsconfig ---'
cat -n packages/vault-sdk/tsconfig.json
printf '%s\n' '--- root tsconfig ---'
cat -n tsconfig.json
printf '%s\n' '--- root package manifest ---'
cat -n package.json
printf '%s\n' '--- package publish-related files ---'
find packages/vault-sdk -maxdepth 1 -type f \
\( -name '.npmignore' -o -name '.gitignore' -o -name 'npm-shrinkwrap.json' -o -name 'README*' \) \
-print -exec sh -c 'echo "--- $1"; cat -n "$1"' sh {} \;
printf '%s\n' '--- package tracked files ---'
git ls-files packages/vault-sdkRepository: clevercon-protocol/clevercon Length of output: 11279 🌐 Web query:
💡 Result: The error ERR_UNKNOWN_FILE_EXTENSION occurs in Node.js when attempting to execute a.ts file that the Node.js runtime does not natively recognize or handle [1][2][3]. Node.js now includes native, lightweight support for TypeScript through type stripping [4][5][6]. If you are encountering this error, it is typically because of one of the following scenarios: 1. Using older Node.js versions or conflicting configurations: If you are using a Node.js version that does not support native type stripping (versions prior to the experimental support) or if you are attempting to use third-party tools like ts-node in an environment where they are not properly registered as loaders, Node.js treats the.ts extension as unknown [2][7][4]. 2. ESM/CommonJS conflicts: When a project is configured with "type": "module" in package.json, Node.js enforces stricter ESM resolution [2][8]. If you run a.ts file directly without the necessary loader or the native type-stripping support enabled, the runtime will throw this error because it does not know how to process the.ts file as an ECMAScript module [2][9]. How to resolve this: - For modern Node.js environments: You can run TypeScript files natively using Node.js without additional loaders [4]. Ensure you are using a recent version of Node.js that supports type stripping (e.g., v22.18.0 or later for stable support) [4]. This built-in support automatically handles.ts files by stripping type annotations and executing the remaining JavaScript [4][5]. - If using third-party tools (e.g., ts-node): If you must use a tool like ts-node for full TypeScript feature support (e.g., support for legacy syntax or specific tsconfig features), you must explicitly register it as a loader [1][2][5]. In ESM projects, this is often done by passing the loader to the node command: node --loader ts-node/esm index.ts Note that older methods (like simply running ts-node via CLI) may fail in newer Node.js versions due to changes in loader handling [10][8]. Many developers have migrated to tools like tsx to simplify this setup [7][8][11]. For more details on your project's specific requirements, consult the official Node.js documentation on TypeScript support [12][5]. Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- SDK TypeScript declarations and syntax ---'
rg -n '^(export )?(enum|namespace|module)|parameter property|@| as const|interface |type ' packages/vault-sdk/src packages/vault-sdk/tsconfig.json || true
printf '%s\n' '--- SDK source imports and exports ---'
rg -n '^(import|export)' packages/vault-sdk/src --glob '*.ts'
printf '%s\n' '--- Node version and package engine metadata ---'
rg -n '"engines"|node-version|setup-node|NODE_VERSION|node [0-9]' \
.github package.json packages/vault-sdk package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null || trueRepository: clevercon-protocol/clevercon Length of output: 32091 Publish compiled JavaScript instead of TypeScript source. The repository targets Node.js 20, but 🤖 Prompt for AI Agents |
||
| "scripts": { | ||
| "test": "vitest run" | ||
| }, | ||
| "dependencies": { | ||
| "@stellar/stellar-sdk": "^14.6.1" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^20.0.0", | ||
| "typescript": "^5.4.0", | ||
| "vitest": "^2.1.9" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { VaultClient } from './client.js'; | ||
| import { createMockVaultClient } from './mock.js'; | ||
| import { VaultErrorCode, VaultContractError } from './errors.js'; | ||
|
|
||
| describe('VaultClient.usdcToStroops / stroopsToUsdc', () => { | ||
| it('converts 1 USDC to 10_000_000 stroops', () => { | ||
| expect(VaultClient.usdcToStroops(1)).toBe(10_000_000n); | ||
| }); | ||
|
|
||
| it('converts 0.5 USDC to 5_000_000 stroops', () => { | ||
| expect(VaultClient.usdcToStroops(0.5)).toBe(5_000_000n); | ||
| }); | ||
|
|
||
| it('converts 10_000_000 stroops to 1 USDC', () => { | ||
| expect(VaultClient.stroopsToUsdc(10_000_000n)).toBe(1); | ||
| }); | ||
|
|
||
| it('round-trips through USDC ↔ stroops', () => { | ||
| const usdc = 42.1234567; | ||
| const stroops = VaultClient.usdcToStroops(usdc); | ||
| expect(VaultClient.stroopsToUsdc(stroops)).toBeCloseTo(usdc, 1); | ||
| }); | ||
| }); | ||
|
|
||
| describe('createMockVaultClient', () => { | ||
| it('is in mock mode', () => { | ||
| const mock = createMockVaultClient(); | ||
| expect(mock.mock).toBe(true); | ||
| expect(mock.active).toBe(true); | ||
| }); | ||
|
|
||
| it('returns zeroed account for unknown user', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const acct = await mock.getAccount('GUNKNOWN'); | ||
| expect(acct).toBeNull(); | ||
| }); | ||
|
|
||
| it('returns version 5', async () => { | ||
| const mock = createMockVaultClient(); | ||
| expect(await mock.version()).toBe(5); | ||
| }); | ||
|
|
||
| it('returns 1800 for stale threshold', async () => { | ||
| const mock = createMockVaultClient(); | ||
| expect(await mock.getStaleThreshold()).toBe(1800); | ||
| }); | ||
|
|
||
| it('returns 50 for max active tasks', async () => { | ||
| const mock = createMockVaultClient(); | ||
| expect(await mock.getMaxActiveTasks()).toBe(50); | ||
| }); | ||
|
|
||
| it('returns zero fee config by default', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const fee = await mock.getFee(); | ||
| expect(fee.bps).toBe(0); | ||
| expect(fee.recipient).toBeNull(); | ||
| }); | ||
|
|
||
| it('mockDeposit increases user balance', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; | ||
| const before = await mock.getBalance(user); | ||
| await mock.mockDeposit(user, 100); | ||
| const after = await mock.getBalance(user); | ||
| expect(after - before).toBe(1_000_000_000n); | ||
| }); | ||
|
|
||
| it('mockRegisterOrchestrator records the mapping', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; | ||
| const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; | ||
| await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); | ||
| const config = await mock.getUserConfig(user); | ||
| expect(config?.orchestrator).toBe(orch); | ||
| expect(config?.orchestrator_name).toBe('TestOrch'); | ||
| }); | ||
|
|
||
| it('mockRegisterOrchestrator throws OrchestratorAlreadyRegistered', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; | ||
| const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; | ||
| await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); | ||
| await expect(mock.mockRegisterOrchestrator(user, orch, 'TestOrch')).rejects.toThrow( | ||
| VaultContractError, | ||
| ); | ||
| }); | ||
|
|
||
| it('mockCreateTask returns sequential task IDs', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; | ||
| const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; | ||
| await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); | ||
| await mock.mockDeposit(user, 100); | ||
|
|
||
| const id1 = await mock.mockCreateTask(orch, 10); | ||
| const id2 = await mock.mockCreateTask(orch, 10); | ||
| expect(id2).toBe(id1 + 1n); | ||
| }); | ||
|
|
||
| it('mockCreateTask throws OrchestratorNotRegistered for unknown orchestrator', async () => { | ||
| const mock = createMockVaultClient(); | ||
| await expect(mock.mockCreateTask('GUNKNOWN', 10)).rejects.toThrow(VaultContractError); | ||
| }); | ||
|
|
||
| it('mockCreateTask throws InsufficientAvailable when balance too low', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; | ||
| const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; | ||
| await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); | ||
| // Don't deposit — balance is 0 | ||
| await expect(mock.mockCreateTask(orch, 10)).rejects.toThrow(VaultContractError); | ||
| }); | ||
|
|
||
| it('getTask returns task info after mockCreateTask', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; | ||
| const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; | ||
| await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); | ||
| await mock.mockDeposit(user, 100); | ||
|
|
||
| const taskId = await mock.mockCreateTask(orch, 10); | ||
| const task = await mock.getTask(taskId); | ||
| expect(task).not.toBeNull(); | ||
| expect(task?.user).toBe(user); | ||
| expect(task?.orchestrator).toBe(orch); | ||
| expect(task?.plan_cost).toBe(100_000_000n); | ||
| expect(task?.completed).toBe(false); | ||
| expect(task?.disputed).toBe(false); | ||
| }); | ||
|
|
||
| it('getTaskStatus returns Active for a new task', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; | ||
| const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; | ||
| await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); | ||
| await mock.mockDeposit(user, 100); | ||
|
|
||
| const taskId = await mock.mockCreateTask(orch, 10); | ||
| const status = await mock.getTaskStatus(taskId); | ||
| expect(status).toBe('Active'); | ||
| }); | ||
|
|
||
| it('taskCount increments after mockCreateTask', async () => { | ||
| const mock = createMockVaultClient(); | ||
| const user = 'GAAAAAAAABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; | ||
| const orch = 'GCCCCCCCDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD'; | ||
| await mock.mockRegisterOrchestrator(user, orch, 'TestOrch'); | ||
| await mock.mockDeposit(user, 100); | ||
|
|
||
| expect(await mock.taskCount()).toBe(0n); | ||
| await mock.mockCreateTask(orch, 10); | ||
| expect(await mock.taskCount()).toBe(1n); | ||
| await mock.mockCreateTask(orch, 5); | ||
| expect(await mock.taskCount()).toBe(2n); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add a language identifier to the architecture fence.
The Markdown lint check reports MD040 for this fence. Use
textas the fence language.🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 116-116: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Source: Linters/SAST tools