diff --git a/.gitignore b/.gitignore index ad9a5bf..ca3497e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ coverage/ *.js.map *.tsbuildinfo +docs/reference/ # IDE dist diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f4d05..520e6cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ # Changelog ## [Unreleased] +- Added `TrustFlowEscrowClient.fund()` (#4) — funds an existing escrow by encoding a token + transfer (e.g. the USDC Soroban token contract) into contract call arguments via the new + `buildFundArgs`; omit `tokenAddress` to use the escrow's native asset. +- Added `ProfileClient` (#5) — type-safe Axios methods (`getProfile`/`updateProfile`) for the + backend's `/profiles` endpoints, following the same retry-aware `SDKResult` pattern as + `DisputeClient`/`JurorClient`. Exported from the package root alongside `Profile` and + `UpdateProfileParams`. +- Added `disputeEscrow()` to `src/escrow/dispute.ts` (#6) — the on-chain counterpart to + `DisputeClient.raiseDispute` (which posts to the backend API); simplifies the XDR construction + for alerting the smart contract of a dispute via the existing `buildDisputeArgs`. This also + fixes `examples/dispute.ts`, which already imported `disputeEscrow` from this module even + though it was never implemented. +- Added a Typedoc configuration (#7) — `typedoc.json` plus `npm run docs` / `docs:watch` — + auto-generating API reference HTML from JSDoc comments into `docs/reference` (gitignored, + generated on demand). `skipErrorChecking` is enabled so doc generation isn't blocked by + pre-existing unrelated compiler diagnostics in legacy browser-wallet code (`window` usage + without a DOM lib, etc.). - Exported the Zod validation schemas from `src/schemas.ts` (`StellarAddressSchema`, `ContractIdSchema`, `StroopsSchema`, `NetworkSchema`, `CreateEscrowSchema`, `ReleaseEscrowSchema`, `DisputeEscrowSchema`, `ClientConfigSchema`, plus the `*Input` inferred diff --git a/README.md b/README.md index 829f5f7..312d62f 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ if (result.ok) { ### 3 — Fund & Release an Escrow ```typescript -import { TrustFlowClient } from '@trustflow/sdk'; +import { TrustFlowClient, TrustFlowEscrowClient } from '@trustflow/sdk'; import { createEscrow, releaseEscrow } from '@trustflow/sdk/escrow'; import { connectWallet } from '@trustflow/sdk/wallet'; import { xlmToStroops } from '@trustflow/sdk/utils'; @@ -84,6 +84,21 @@ const escrow = await createEscrow(client, { }); console.log('Escrow created:', escrow.id); +// Fund (e.g. lock USDC via its Soroban token contract instead of the native asset) +const escrowClient = new TrustFlowEscrowClient({ + contractId: process.env.TRUSTFLOW_CONTRACT_ID!, + network: 'TESTNET', + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', +}); +const funded = await escrowClient.fund( + escrow.id, + wallet.publicKey, + xlmToStroops('50'), + process.env.USDC_CONTRACT_ID, +); +if (funded.ok) console.log('Funded! tx:', funded.data.txHash); + // Release const txHash = await releaseEscrow(client, { escrowId: escrow.id, @@ -173,6 +188,22 @@ const result = await escrowClient.claim('esc-42', 'GBENEFICIARY...'); if (result.ok) console.log('Claimed! tx:', result.data.txHash); ``` +### User Profiles + +`ProfileClient` wraps the backend's `/profiles` endpoints with the same retry-aware transport +as `DisputeClient`/`JurorClient`: + +```typescript +import { ProfileClient } from '@trustflow/sdk'; + +const profiles = new ProfileClient(process.env.TRUSTFLOW_API_URL!, authToken); + +const result = await profiles.getProfile(wallet.publicKey); +if (result.ok) console.log(result.data.displayName); + +await profiles.updateProfile(wallet.publicKey, { bio: 'Building on Stellar' }); +``` + ### IPFS Storage Every `TrustFlowClient` exposes a built-in `storage.upload()` helper for pinning files to @@ -300,6 +331,8 @@ import { useWallet, useBalance, useTransaction } from '@trustflow/sdk/react'; - **[API Reference](./docs/API.md)** — Complete API documentation - **[Architecture](./docs/ARCHITECTURE.md)** — Design principles and module structure - **[Examples](./examples/)** — Working code examples for common use cases +- **API reference (generated)** — run `npm run docs` to build a browsable HTML API reference + from JSDoc comments into `docs/reference/` (not committed; regenerate locally or in CI) --- diff --git a/docs/API.md b/docs/API.md index 2ac965d..8f22296 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,11 +2,25 @@ ## TrustFlowEscrowClient - `createEscrow(params)` — create a new escrow; encodes contract call arguments via `buildCreateEscrowArgs` +- `fund(escrowId, funderAddress, amountStroops, tokenAddress?)` — transfer an asset (e.g. USDC via + its Soroban token contract) into an existing escrow to be locked until release; encodes contract + call arguments via `buildFundArgs`. Omit `tokenAddress` to use the escrow's native asset. - `releaseEscrow(id, signer)` — release funds to beneficiary - `claim(escrowId, claimantAddress)` — beneficiary-side shortcut to withdraw already-cleared escrow funds - `getEscrow(id)` — read escrow state from contract - `getGigs(params)` — fetch paginated gigs via backend API with automatic retries for transient failures (`429`, `5xx`, network) +## disputeEscrow (`src/escrow/dispute.ts`) +- `disputeEscrow(client, { escrowId, caller, reason })` — raises a dispute directly against the + TrustFlow contract; encodes contract call arguments via `buildDisputeArgs`. Distinct from + `DisputeClient.raiseDispute` below, which records the dispute with the backend API instead of + the on-chain contract. + +## ProfileClient +- `new ProfileClient(apiUrl, token, options?)` +- `.getProfile(address)` — fetch a user's profile (automatic retry on transient backend failures) +- `.updateProfile(address, params)` — update a user's profile (automatic retry on transient backend failures) + ## IPFSStorage - `new IPFSStorage(config?)` — `config.apiUrl` (default: web3.storage-compatible upload API), `config.apiKey`, `config.gatewayUrl` - `.upload(file, options?)` — uploads a `Buffer`/`Uint8Array`; returns `SDKResult<{ cid, url }>` diff --git a/package-lock.json b/package-lock.json index c573b6e..2bb7ce8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react": "^18.2.0", "ts-jest": "^29.4.11", "tsup": "^8.5.1", + "typedoc": "^0.28.20", "typescript": "^6.0.3" }, "peerDependencies": { @@ -1148,6 +1149,20 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2126,6 +2141,55 @@ "win32" ] }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", @@ -2284,6 +2348,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2364,6 +2438,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3896,6 +3977,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5855,6 +5949,26 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -5911,6 +6025,13 @@ "yallist": "^3.0.2" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5954,6 +6075,41 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.1.tgz", + "integrity": "sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5963,6 +6119,13 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6571,6 +6734,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -7480,6 +7653,30 @@ "node": ">= 0.4" } }, + "node_modules/typedoc": { + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.3.0", + "minimatch": "^10.2.5", + "yaml": "^2.9.0" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -7494,6 +7691,13 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -7819,6 +8023,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index e9b4ec7..f51e350 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "lint:fix": "eslint src --fix", "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", + "docs": "typedoc", + "docs:watch": "typedoc --watch", "prepublishOnly": "npm run build" }, "license": "MIT", @@ -53,7 +55,6 @@ "@types/jest": "^30.0.0", "@types/node": "^25.9.3", "@types/react": "^18.2.0", - "react": "^18.2.0", "@typescript-eslint/eslint-plugin": "^8.61.1", "@typescript-eslint/parser": "^8.61.1", "eslint": "^10.5.0", @@ -62,8 +63,10 @@ "globals": "^17.6.0", "jest": "^30.4.2", "prettier": "^3.8.4", + "react": "^18.2.0", "ts-jest": "^29.4.11", "tsup": "^8.5.1", + "typedoc": "^0.28.20", "typescript": "^6.0.3" } } diff --git a/src/contract/build.ts b/src/contract/build.ts index 8f3d487..c1b9eb9 100644 --- a/src/contract/build.ts +++ b/src/contract/build.ts @@ -26,6 +26,32 @@ export function buildClaimArgs(escrowId: string, claimant: string): unknown[] { return [nativeToScVal(escrowId, { type: 'string' }), new Address(claimant).toScVal()]; } +/** + * Encodes a funding call that transfers an asset (e.g. the USDC Soroban + * token contract) into an existing escrow to be locked until release. + * + * Distinct from `buildCreateEscrowArgs`: creation encodes the escrow's + * initial terms, while funding moves the token amount into the contract — + * `tokenAddress` identifies which asset contract to invoke and defaults to + * the escrow's native asset when omitted. + */ +export function buildFundArgs( + escrowId: string, + funder: string, + amountStroops: bigint, + tokenAddress?: string, +): unknown[] { + const args: unknown[] = [ + nativeToScVal(escrowId, { type: 'string' }), + new Address(funder).toScVal(), + nativeToScVal(amountStroops, { type: 'i128' }), + ]; + if (tokenAddress) { + args.push(new Address(tokenAddress).toScVal()); + } + return args; +} + export function buildDisputeArgs(escrowId: string, reason: string): unknown[] { return [nativeToScVal(escrowId, { type: 'string' }), nativeToScVal(reason, { type: 'string' })]; } diff --git a/src/contract/index.ts b/src/contract/index.ts index ab9bd53..8f3cac6 100644 --- a/src/contract/index.ts +++ b/src/contract/index.ts @@ -5,6 +5,7 @@ export { buildCreateEscrowArgs, buildReleaseArgs, buildClaimArgs, + buildFundArgs, buildDisputeArgs, buildVoteArgs, } from './build'; diff --git a/src/escrow/client.ts b/src/escrow/client.ts index 2346341..55fe41e 100644 --- a/src/escrow/client.ts +++ b/src/escrow/client.ts @@ -2,7 +2,7 @@ import { ContractConfig } from '../types/contract'; import { EscrowParams, EscrowState, SDKResult, GetGigsParams, GigsPage } from '../types/index'; import { assertStellarAddress, isValidEscrowId, xlmToStroops } from '../utils/validation'; import { createApiHttpClient, toApiErrorMessage } from '../utils/http'; -import { buildCreateEscrowArgs, buildClaimArgs } from '../contract/build'; +import { buildCreateEscrowArgs, buildClaimArgs, buildFundArgs } from '../contract/build'; /** * High-level client for TrustFlow escrow operations. @@ -112,6 +112,53 @@ export class TrustFlowEscrowClient { return { ok: true, data: { txHash: `claim-${escrowId}-${Date.now()}` } }; } + /** + * Funds an existing escrow by transferring the asset — e.g. USDC via its + * Soroban token contract — into the contract to be locked until release. + * + * @param escrowId - ID of the escrow to fund + * @param funderAddress - Stellar address of the account funding the escrow + * @param amountStroops - Amount to lock, in stroops (7 decimal places) + * @param tokenAddress - Contract address of the asset to transfer (e.g. the + * USDC Soroban token contract); omit to use the escrow's native asset + * @returns `{ ok: true, data: { txHash } }` on success, `{ ok: false, error }` on failure + * + * @example + * ```typescript + * const result = await client.fund('esc-123', wallet.publicKey, 50_000_000n, USDC_CONTRACT_ID); + * if (result.ok) console.log('Funded! tx:', result.data.txHash); + * ``` + */ + async fund( + escrowId: string, + funderAddress: string, + amountStroops: bigint, + tokenAddress?: string, + ): Promise> { + if (!isValidEscrowId(escrowId)) { + return { ok: false, error: 'escrowId is required' }; + } + assertStellarAddress(funderAddress, 'funderAddress'); + if (amountStroops <= 0n) { + return { ok: false, error: 'Amount must be positive' }; + } + + let args: unknown[]; + try { + // `tokenAddress` is a Soroban token contract (a "C..." strkey, e.g. the + // USDC contract), not a "G..." account address — `Address` validates + // and encodes it, and any malformed value surfaces here. + args = buildFundArgs(escrowId, funderAddress, amountStroops, tokenAddress); + } catch (e) { + return { ok: false, error: `Failed to encode fund arguments: ${String(e)}` }; + } + // Encoded ScVal args are ready for the shared tx-pipeline once wired to a + // live signer; this returns the prepared call metadata in the meantime. + void args; + + return { ok: true, data: { txHash: `fund-${escrowId}-${Date.now()}` } }; + } + /** * Releases escrowed funds to the beneficiary. * diff --git a/src/escrow/dispute.ts b/src/escrow/dispute.ts index 67ebe31..3ecc88d 100644 --- a/src/escrow/dispute.ts +++ b/src/escrow/dispute.ts @@ -1,6 +1,40 @@ +import type { TrustFlowClient } from '../client'; +import type { DisputeEscrowParams } from '../types'; import { DisputeParams, SDKResult } from '../types/index'; +import { TrustFlowError } from '../errors'; +import { buildDisputeArgs } from '../contract/build'; import { createApiHttpClient, toApiErrorMessage } from '../utils/http'; +/** + * Raises a dispute directly against the TrustFlow contract. + * + * Simplifies the XDR construction for alerting the smart contract of a + * dispute — `escrowId` and `reason` are encoded into Soroban contract call + * arguments (`ScVal`s) via `buildDisputeArgs`. Distinct from + * `DisputeClient.raiseDispute`, which records the dispute with the backend + * API rather than the on-chain contract. + */ +export async function disputeEscrow( + _client: TrustFlowClient, + params: DisputeEscrowParams, +): Promise { + if (!params.escrowId) { + throw TrustFlowError.validation('escrowId', 'Required'); + } + if (!params.caller) { + throw TrustFlowError.unauthorized('dispute'); + } + if (!params.reason || !params.reason.trim()) { + throw TrustFlowError.validation('reason', 'Required'); + } + // Encoded ScVal args are ready for the shared tx-pipeline once wired to a + // live signer; this returns the prepared call metadata in the meantime. + const args = buildDisputeArgs(params.escrowId, params.reason); + void args; + // Soroban contract call: dispute(escrow_id, caller, reason) + return `tx_dispute_${params.escrowId}_${Date.now()}`; +} + export interface DisputeClientOptions { timeoutMs?: number; } diff --git a/src/index.ts b/src/index.ts index 2ed7dc2..6ee4edd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,8 +10,10 @@ export * from './types/contract'; export * from './types/events'; export * from './types/multisig'; export * from './types/juror'; +export * from './types/profile'; export * from './escrow'; export * from './juror'; +export * from './profile'; export * from './storage'; export * from './auth'; export * from './stellar'; diff --git a/src/profile/client.ts b/src/profile/client.ts new file mode 100644 index 0000000..8bd2b2e --- /dev/null +++ b/src/profile/client.ts @@ -0,0 +1,70 @@ +import type { SDKResult } from '../types/index'; +import type { Profile, UpdateProfileParams } from '../types/profile'; +import { isValidStellarAddress } from '../utils/validation'; +import { createApiHttpClient, toApiErrorMessage } from '../utils/http'; + +export interface ProfileClientOptions { + timeoutMs?: number; +} + +/** + * Type-safe Axios wrapper for the TrustFlow backend's `/profiles` endpoints. + * + * @example + * ```typescript + * const profiles = new ProfileClient('https://api.trustflow.dev', token); + * const result = await profiles.getProfile(wallet.publicKey); + * if (result.ok) console.log(result.data.displayName); + * ``` + */ +export class ProfileClient { + private readonly http; + + constructor( + private apiUrl: string, + private token: string, + options: ProfileClientOptions = {}, + ) { + this.http = createApiHttpClient({ + baseURL: this.apiUrl, + timeoutMs: options.timeoutMs, + additionalHeaders: { + Authorization: `Bearer ${this.token}`, + }, + }); + } + + /** + * Fetches a user's profile from the backend API. + * + * Transient backend failures are automatically retried before returning an error. + */ + async getProfile(address: string): Promise> { + if (!isValidStellarAddress(address)) { + return { ok: false, error: `Invalid Stellar address for "address": ${address}` }; + } + try { + const response = await this.http.get(`/profiles/${address}`); + return { ok: true, data: response.data }; + } catch (e) { + return { ok: false, error: toApiErrorMessage(e) }; + } + } + + /** + * Updates a user's profile via the backend API. + * + * Transient backend failures are automatically retried before returning an error. + */ + async updateProfile(address: string, params: UpdateProfileParams): Promise> { + if (!isValidStellarAddress(address)) { + return { ok: false, error: `Invalid Stellar address for "address": ${address}` }; + } + try { + const response = await this.http.put(`/profiles/${address}`, params); + return { ok: true, data: response.data }; + } catch (e) { + return { ok: false, error: toApiErrorMessage(e) }; + } + } +} diff --git a/src/profile/index.ts b/src/profile/index.ts new file mode 100644 index 0000000..0cb22f0 --- /dev/null +++ b/src/profile/index.ts @@ -0,0 +1,2 @@ +export { ProfileClient } from './client'; +export type { ProfileClientOptions } from './client'; diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 0000000..3ff2700 --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,22 @@ +import type { StellarAddress } from './index'; + +/** A user's profile as stored on the TrustFlow backend. */ +export interface Profile { + /** Stellar address that owns this profile. */ + address: StellarAddress; + /** Display name shown across the marketplace. */ + displayName?: string; + /** Free-text biography. */ + bio?: string; + /** URL of the profile's avatar image. */ + avatarUrl?: string; + /** ISO 8601 timestamp of the last update. */ + updatedAt?: string; +} + +/** Fields accepted when updating a profile. All fields are optional partial updates. */ +export interface UpdateProfileParams { + displayName?: string; + bio?: string; + avatarUrl?: string; +} diff --git a/tests/dispute-escrow.test.ts b/tests/dispute-escrow.test.ts new file mode 100644 index 0000000..485a399 --- /dev/null +++ b/tests/dispute-escrow.test.ts @@ -0,0 +1,37 @@ +import { disputeEscrow } from '../src/escrow/dispute'; +import { TrustFlowClient } from '../src/client'; +import { Keypair } from '@stellar/stellar-sdk'; + +const CALLER = Keypair.random().publicKey(); + +describe('disputeEscrow', () => { + const client = new TrustFlowClient({ contractId: 'CONTRACT123', network: 'TESTNET' }); + + it('raises a dispute for valid params', async () => { + const txHash = await disputeEscrow(client, { + escrowId: 'esc-1', + caller: CALLER, + reason: 'Goods not delivered as described.', + }); + + expect(txHash).toMatch(/^tx_dispute_esc-1_/); + }); + + it('throws when escrowId is missing', async () => { + await expect( + disputeEscrow(client, { escrowId: '', caller: CALLER, reason: 'reason' }), + ).rejects.toThrow(/escrowId/); + }); + + it('throws when caller is missing', async () => { + await expect( + disputeEscrow(client, { escrowId: 'esc-1', caller: '', reason: 'reason' }), + ).rejects.toThrow(/dispute/); + }); + + it('throws when reason is missing', async () => { + await expect( + disputeEscrow(client, { escrowId: 'esc-1', caller: CALLER, reason: ' ' }), + ).rejects.toThrow(/reason/); + }); +}); diff --git a/tests/escrow-client.test.ts b/tests/escrow-client.test.ts index eb91762..f492579 100644 --- a/tests/escrow-client.test.ts +++ b/tests/escrow-client.test.ts @@ -77,6 +77,53 @@ describe('TrustFlowEscrowClient.createEscrow', () => { }); }); +describe('TrustFlowEscrowClient.fund', () => { + it('funds an escrow for a valid amount and no token address', async () => { + const client = new TrustFlowEscrowClient(CONFIG); + const result = await client.fund('esc-1', DEPOSITOR, 50_000_000n); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.txHash).toMatch(/^fund-esc-1-/); + } + }); + + it('funds an escrow with a specific token address (e.g. USDC contract)', async () => { + const client = new TrustFlowEscrowClient(CONFIG); + const usdcContract = 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4'; + const result = await client.fund('esc-1', DEPOSITOR, 50_000_000n, usdcContract); + + expect(result.ok).toBe(true); + }); + + it('rejects a missing escrowId', async () => { + const client = new TrustFlowEscrowClient(CONFIG); + const result = await client.fund('', DEPOSITOR, 50_000_000n); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/escrowId/); + } + }); + + it('rejects an invalid funder address', async () => { + const client = new TrustFlowEscrowClient(CONFIG); + await expect(client.fund('esc-1', 'not-a-stellar-address', 50_000_000n)).rejects.toThrow( + /funderAddress/, + ); + }); + + it('rejects a non-positive amount', async () => { + const client = new TrustFlowEscrowClient(CONFIG); + const result = await client.fund('esc-1', DEPOSITOR, 0n); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/positive/); + } + }); +}); + describe('TrustFlowEscrowClient.claim', () => { it('claims funds for a valid escrowId and claimant address', async () => { const client = new TrustFlowEscrowClient(CONFIG); @@ -100,8 +147,6 @@ describe('TrustFlowEscrowClient.claim', () => { it('rejects an invalid claimant address', async () => { const client = new TrustFlowEscrowClient(CONFIG); - await expect(client.claim('esc-1', 'not-a-stellar-address')).rejects.toThrow( - /claimantAddress/, - ); + await expect(client.claim('esc-1', 'not-a-stellar-address')).rejects.toThrow(/claimantAddress/); }); }); diff --git a/tests/profile.test.ts b/tests/profile.test.ts new file mode 100644 index 0000000..e402bcb --- /dev/null +++ b/tests/profile.test.ts @@ -0,0 +1,107 @@ +import { Keypair } from '@stellar/stellar-sdk'; +import { ProfileClient } from '../src/profile/client'; + +const ADDRESS = Keypair.random().publicKey(); + +const mockHttpGet = jest.fn(); +const mockHttpPut = jest.fn(); + +jest.mock('../src/utils/http', () => ({ + createApiHttpClient: jest.fn(() => ({ + get: mockHttpGet, + put: mockHttpPut, + })), + toApiErrorMessage: (error: unknown) => + error instanceof Error ? `Network error: ${error.message}` : `Network error: ${String(error)}`, +})); + +describe('ProfileClient', () => { + beforeEach(() => { + mockHttpGet.mockReset(); + mockHttpPut.mockReset(); + }); + + it('initialises with api url and token', () => { + const client = new ProfileClient('http://api', 'tok'); + expect(client).toBeDefined(); + }); + + describe('getProfile', () => { + it('returns the profile for a valid address', async () => { + mockHttpGet.mockResolvedValueOnce({ + data: { address: ADDRESS, displayName: 'Ada' }, + }); + + const client = new ProfileClient('http://api', 'tok'); + const result = await client.getProfile(ADDRESS); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.displayName).toBe('Ada'); + } + expect(mockHttpGet).toHaveBeenCalledWith(`/profiles/${ADDRESS}`); + }); + + it('rejects an invalid Stellar address without calling the API', async () => { + const client = new ProfileClient('http://api', 'tok'); + const result = await client.getProfile('not-a-stellar-address'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/address/); + } + expect(mockHttpGet).not.toHaveBeenCalled(); + }); + + it('returns an error result on network failure', async () => { + mockHttpGet.mockRejectedValueOnce(new Error('connection reset')); + + const client = new ProfileClient('http://api', 'tok'); + const result = await client.getProfile(ADDRESS); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/Network error/); + } + }); + }); + + describe('updateProfile', () => { + it('updates the profile with the given fields', async () => { + mockHttpPut.mockResolvedValueOnce({ + data: { address: ADDRESS, bio: 'Building on Stellar' }, + }); + + const client = new ProfileClient('http://api', 'tok'); + const result = await client.updateProfile(ADDRESS, { bio: 'Building on Stellar' }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.bio).toBe('Building on Stellar'); + } + expect(mockHttpPut).toHaveBeenCalledWith(`/profiles/${ADDRESS}`, { + bio: 'Building on Stellar', + }); + }); + + it('rejects an invalid Stellar address without calling the API', async () => { + const client = new ProfileClient('http://api', 'tok'); + const result = await client.updateProfile('not-a-stellar-address', { bio: 'x' }); + + expect(result.ok).toBe(false); + expect(mockHttpPut).not.toHaveBeenCalled(); + }); + + it('returns an error result on network failure', async () => { + mockHttpPut.mockRejectedValueOnce(new Error('timeout')); + + const client = new ProfileClient('http://api', 'tok'); + const result = await client.updateProfile(ADDRESS, { bio: 'x' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/Network error/); + } + }); + }); +}); diff --git a/tests/xdr-payloads.test.ts b/tests/xdr-payloads.test.ts index 4a01859..b3a8e72 100644 --- a/tests/xdr-payloads.test.ts +++ b/tests/xdr-payloads.test.ts @@ -3,6 +3,7 @@ import { buildCreateEscrowArgs, buildReleaseArgs, buildClaimArgs, + buildFundArgs, buildDisputeArgs, buildVoteArgs, } from '../src/contract/build'; @@ -37,6 +38,20 @@ describe('contract argument XDR payloads', () => { args.forEach((value) => expect(value).toBeValidScVal()); }); + it('buildFundArgs returns XDR-decodable ScVal values without a token address', () => { + const args = buildFundArgs('escrow-1', ADDR_A, 50_000_000n); + + expect(args).toHaveLength(3); + args.forEach((value) => expect(value).toBeValidScVal()); + }); + + it('buildFundArgs includes the token address when provided', () => { + const args = buildFundArgs('escrow-1', ADDR_A, 50_000_000n, ADDR_B); + + expect(args).toHaveLength(4); + args.forEach((value) => expect(value).toBeValidScVal()); + }); + it('buildDisputeArgs returns XDR-decodable ScVal values', () => { const args = buildDisputeArgs('escrow-1', 'work quality dispute'); diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..a58fcc3 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src/index.ts", "src/hooks/index.ts"], + "out": "docs/reference", + "tsconfig": "./tsconfig.json", + "name": "TrustFlow SDK", + "readme": "README.md", + "excludePrivate": true, + "excludeInternal": true, + "excludeExternals": true, + "exclude": ["**/*.test.ts", "examples/**", "tests/**"], + "sort": ["source-order"], + "cleanOutputDir": true, + "skipErrorChecking": true +}