diff --git a/CHANGELOG.md b/CHANGELOG.md index 520e6cf..6a30fc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ # Changelog ## [Unreleased] +- Added `./escrow`, `./wallet`, and `./utils` subpath exports (#100) — the + README's Quick Start (`import { createEscrow } from '@trustflow/sdk/escrow'`, + and likewise `/wallet`, `/utils`) previously failed with + `ERR_PACKAGE_PATH_NOT_EXPORTED` for consumers of the published package + because only `.` and `./react` were declared. Each is wired into + `tsup.config.ts`'s `entry` and `package.json`'s `exports` with matching + `types` / `import` / `require` conditions, and a test asserts every + `@trustflow/sdk/*` path in the README resolves against the exports map. +- Removed the dead duplicate `submitTransaction` in `src/stellar/horizon.ts` + (#110) — the file was orphaned (not on the `src/stellar` barrel, no import + sites; `MultiSigEscrowClient.submitWhenReady` uses the `(xdr, horizonUrl)` + version from `src/stellar/transaction.ts`). A test guards against a second + implementation reappearing. +- Exported `useEscrow` from `src/hooks/index.ts` (#107) — its + `createEscrow` / `releaseEscrow` imports resolve against the real + re-exports in `src/escrow/index.ts`, so the hook type-checks; added tests + for its create / release success and error paths. Still ships only from the + `@trustflow/sdk/react` subpath (#81). +- Added a Soroban RPC smoke test for the contract-invocation layer (#104) — + `src/contract/{invoke,read,simulate}.ts` already use `rpc` (not the + nonexistent `SorobanRpc`) against `@stellar/stellar-sdk@15`; the test + constructs `rpc.Server` from `src/contract/index.ts`'s dependency graph so a + future SDK bump breaking this is caught immediately. - 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. diff --git a/README.md b/README.md index 312d62f..186e8ad 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,9 @@ React hooks for wallet, balance, and transaction state are available from the `@ import { useWallet, useBalance, useTransaction } from '@trustflow/sdk/react'; ``` -`react` (`^18.0.0 || ^19.0.0`) is a peer dependency, required only if you import from `/react`. Note: `useEscrow` is not yet exported here — it's implemented against an API that doesn't currently exist on `TrustFlowClient` (tracked in [#81](https://github.com/trustflow-protocol/trustflow-sdk/issues/81)). +`react` (`^18.0.0 || ^19.0.0`) is a peer dependency, required only if you import from `/react`. `useEscrow` is exported here too — it wraps the standalone `createEscrow` / `releaseEscrow` functions with loading / error state. + +The `@trustflow/sdk/escrow`, `@trustflow/sdk/wallet`, and `@trustflow/sdk/utils` subpaths used in the Quick Start above are declared in `package.json`'s `exports` and built as their own targets, so those imports resolve against the published package as well as from source. --- diff --git a/package.json b/package.json index e15a6f2..0ee9f99 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,21 @@ "types": "./dist/hooks/index.d.ts", "import": "./dist/hooks/index.mjs", "require": "./dist/hooks/index.js" + }, + "./escrow": { + "types": "./dist/escrow/index.d.ts", + "import": "./dist/escrow/index.mjs", + "require": "./dist/escrow/index.js" + }, + "./wallet": { + "types": "./dist/wallet/index.d.ts", + "import": "./dist/wallet/index.mjs", + "require": "./dist/wallet/index.js" + }, + "./utils": { + "types": "./dist/utils/index.d.ts", + "import": "./dist/utils/index.mjs", + "require": "./dist/utils/index.js" } }, "files": [ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index cbd10dc..f848237 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,16 +1,13 @@ export { useWallet } from './useWallet'; export { useBalance } from './useBalance'; export { useTransaction } from './useTransaction'; +export { useEscrow } from './useEscrow'; -// `useEscrow` is intentionally NOT exported here (#81). It imports -// `createEscrow`/`releaseEscrow` as free functions from '../escrow' that -// don't exist there — that module only exports classes (including -// `TrustFlowEscrowClient`, which *does* have `createEscrow`/`releaseEscrow` -// methods, but isn't what this hook's `client: TrustFlowClient` parameter -// accepts; `TrustFlowClient` itself exposes no escrow methods at all). This -// was invisible until now because no build entry point ever pulled in -// `src/hooks/`, so the mismatch never got type-checked. Re-wiring -// `useEscrow` against the real API is a separate, non-trivial fix (which -// class/instance the hook should actually take, or whether `TrustFlowClient` -// should grow an `escrow` accessor) and is left for a follow-up rather than -// guessed at here. +// `useEscrow` calls the free functions `createEscrow(client, params)` / +// `releaseEscrow(client, params)`. Both are re-exported by +// `src/escrow/index.ts` (from `create.ts` / `release.ts`) and both take a +// `client: TrustFlowClient` and work through `invokeContract`, so the hook +// type-checks and its create / release paths hit the real functions (#107). +// Like the other hooks it ships only from the `@trustflow/sdk/react` subpath, +// not the package root, so non-React consumers aren't forced to install +// `react` (#81). diff --git a/src/stellar/horizon.ts b/src/stellar/horizon.ts deleted file mode 100644 index 9db9b25..0000000 --- a/src/stellar/horizon.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { TrustFlowClient } from '../client'; -import { TrustFlowError } from '../errors'; - -interface HorizonSubmitResponse { - hash: string; - successful: boolean; - ledger?: number; - extras?: { result_codes?: { transaction?: string } }; -} - -export async function submitTransaction( - client: TrustFlowClient, - signedXdr: string, -): Promise { - const server = client.getServer(); - try { - const result = await server.submitTransaction( - (await import('@stellar/stellar-sdk')).TransactionBuilder.fromXDR( - signedXdr, - client.network === 'MAINNET' - ? (await import('@stellar/stellar-sdk')).Networks.PUBLIC - : (await import('@stellar/stellar-sdk')).Networks.TESTNET, - ), - ); - return (result as HorizonSubmitResponse).hash; - } catch (e: unknown) { - const err = e as { response?: { data?: { extras?: { result_codes?: { transaction?: string } } } } }; - throw new TrustFlowError( - err?.response?.data?.extras?.result_codes?.transaction ?? 'Submission failed', - 'CONTRACT_ERROR', - e, - ); - } -} diff --git a/tests/contract-rpc-smoke.test.ts b/tests/contract-rpc-smoke.test.ts new file mode 100644 index 0000000..8a9ede2 --- /dev/null +++ b/tests/contract-rpc-smoke.test.ts @@ -0,0 +1,29 @@ +import { rpc } from '@stellar/stellar-sdk'; +import * as contract from '../src/contract'; + +/** + * #104 — `src/contract/{invoke,read,simulate}.ts` previously imported a + * nonexistent `SorobanRpc` from `@stellar/stellar-sdk@15`. They now use `rpc`. + * This fails immediately if a future SDK bump renames or drops that export, + * or reintroduces the broken name. + */ +describe('contract-invocation RPC wiring (#104)', () => { + it('re-exports the invocation functions from src/contract/index.ts', () => { + expect(typeof contract.invokeContract).toBe('function'); + expect(typeof contract.readContractState).toBe('function'); + expect(typeof contract.simulateContractCall).toBe('function'); + }); + + it('the pinned @stellar/stellar-sdk exposes `rpc` and `rpc.Server` constructs', () => { + expect(rpc).toBeDefined(); + expect(typeof rpc.Server).toBe('function'); + const server = new rpc.Server('https://soroban-testnet.stellar.org'); + expect(server).toBeInstanceOf(rpc.Server); + }); + + it('the old `SorobanRpc` name stays gone', () => { + const sdk = require('@stellar/stellar-sdk') as Record; + expect(sdk.SorobanRpc).toBeUndefined(); + expect(sdk.rpc).toBeDefined(); + }); +}); diff --git a/tests/exports-resolution.test.ts b/tests/exports-resolution.test.ts new file mode 100644 index 0000000..6aec69c --- /dev/null +++ b/tests/exports-resolution.test.ts @@ -0,0 +1,74 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * #100 — every import path the README and examples show must resolve against + * the published package's `exports` map, and every non-root subpath must be a + * tsup build entry so `dist//index.*` actually gets built. + */ + +const root = path.resolve(__dirname, '..'); +const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as { + name: string; + exports: Record>; +}; +const readme = fs.readFileSync(path.join(root, 'README.md'), 'utf8'); +const tsup = fs.readFileSync(path.join(root, 'tsup.config.ts'), 'utf8'); + +function packageImportSpecifiers(source: string): string[] { + const re = new RegExp( + `from\\s+['"](${pkg.name.replace('/', '\\/')}(?:\\/[^'"]+)?)['"]`, + 'g', + ); + const found = new Set(); + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + if (m[1]) found.add(m[1]); + } + return [...found]; +} + +function specToSubpath(spec: string): string { + return spec === pkg.name ? '.' : '.' + spec.slice(pkg.name.length); +} + +describe('package exports map (#100)', () => { + const exportKeys = Object.keys(pkg.exports); + + it('declares the documented non-root subpaths', () => { + expect(exportKeys).toEqual( + expect.arrayContaining(['.', './react', './escrow', './wallet', './utils']), + ); + }); + + it('every condition of every subpath is a valid condition pointing at a dist path', () => { + for (const conditions of Object.values(pkg.exports)) { + for (const [condition, target] of Object.entries(conditions)) { + expect(['types', 'import', 'require']).toContain(condition); + expect(target).toMatch(/^\.\/dist\/.+\.(d\.ts|mjs|js)$/); + } + } + }); + + it('every @trustflow/sdk import path in README.md resolves against the exports map', () => { + const specs = packageImportSpecifiers(readme); + expect(specs.length).toBeGreaterThan(0); + for (const spec of specs) { + expect(exportKeys).toContain(specToSubpath(spec)); + } + }); + + it('every non-root subpath is wired as a tsup build entry', () => { + const entryForSubpath: Record = { + './react': 'src/hooks/index.ts', + './escrow': 'src/escrow/index.ts', + './wallet': 'src/wallet/index.ts', + './utils': 'src/utils/index.ts', + }; + for (const [subpath, entry] of Object.entries(entryForSubpath)) { + if (exportKeys.includes(subpath)) { + expect(tsup).toContain(entry); + } + } + }); +}); diff --git a/tests/stellar-single-submit.test.ts b/tests/stellar-single-submit.test.ts new file mode 100644 index 0000000..6a431db --- /dev/null +++ b/tests/stellar-single-submit.test.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { submitTransaction } from '../src/stellar/transaction'; +import * as stellarBarrel from '../src/stellar'; + +/** + * #110 — there was a dead duplicate `submitTransaction` in + * `src/stellar/horizon.ts` (orphaned: not on the barrel, no import sites). The + * used implementation lives in `src/stellar/transaction.ts`. Only one may exist. + */ +describe('src/stellar submitTransaction (#110)', () => { + const stellarDir = path.resolve(__dirname, '../src/stellar'); + + it('has exactly one implementation across src/stellar', () => { + const withImpl = fs + .readdirSync(stellarDir) + .filter((file) => file.endsWith('.ts')) + .filter((file) => + /export\s+async\s+function\s+submitTransaction\b/.test( + fs.readFileSync(path.join(stellarDir, file), 'utf8'), + ), + ); + expect(withImpl).toEqual(['transaction.ts']); + }); + + it('src/stellar/horizon.ts no longer exists', () => { + expect(fs.existsSync(path.join(stellarDir, 'horizon.ts'))).toBe(false); + }); + + it('the surviving implementation is (xdr, horizonUrl) and is the one on the barrel', () => { + expect(typeof submitTransaction).toBe('function'); + expect(submitTransaction).toHaveLength(2); + expect((stellarBarrel as { submitTransaction?: unknown }).submitTransaction).toBe( + submitTransaction, + ); + }); +}); diff --git a/tests/use-escrow.test.ts b/tests/use-escrow.test.ts new file mode 100644 index 0000000..25efab2 --- /dev/null +++ b/tests/use-escrow.test.ts @@ -0,0 +1,99 @@ +/** + * #107 — `useEscrow`'s create / release paths. React is stubbed so the hook + * runs under Jest's node environment: `useState` is backed by a per-render + * cell array, `useCallback` returns the function unchanged. + */ + +let cells: unknown[] = []; +let cursor = 0; + +jest.mock('react', () => ({ + useState: (init: unknown) => { + const i = cursor++; + if (!(i in cells)) { + cells[i] = typeof init === 'function' ? (init as () => unknown)() : init; + } + const setter = (next: unknown) => { + cells[i] = typeof next === 'function' ? (next as (p: unknown) => unknown)(cells[i]) : next; + }; + return [cells[i], setter]; + }, + useCallback: (fn: unknown) => fn, +})); + +jest.mock('../src/escrow', () => ({ + createEscrow: jest.fn(), + releaseEscrow: jest.fn(), +})); + +import * as escrow from '../src/escrow'; +import { useEscrow } from '../src/hooks/useEscrow'; + +const mockCreate = escrow.createEscrow as jest.Mock; +const mockRelease = escrow.releaseEscrow as jest.Mock; + +function render() { + cells = []; + cursor = 0; + return useEscrow({} as never); +} + +// hook returns [loading, error, escrow] cells in this order +const loading = () => cells[0] as boolean; +const error = () => cells[1] as string | null; + +describe('useEscrow — create (#107)', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns the escrow, stores it, and clears error/loading on success', async () => { + const created = { id: 'escrow-1', status: 'PENDING' }; + mockCreate.mockResolvedValueOnce(created); + + const hook = render(); + const result = await hook.create({ + sender: 'GA', recipient: 'GB', amountStroops: 100n, + } as never); + + expect(result).toBe(created); + expect(mockCreate).toHaveBeenCalledWith({}, expect.objectContaining({ sender: 'GA' })); + expect(cells[2]).toBe(created); // escrow cell + expect(error()).toBeNull(); + expect(loading()).toBe(false); + }); + + it('surfaces the error message and rethrows on failure', async () => { + mockCreate.mockRejectedValueOnce(new Error('minimum amount not met')); + + const hook = render(); + await expect(hook.create({} as never)).rejects.toThrow('minimum amount not met'); + + expect(error()).toBe('minimum amount not met'); + expect(loading()).toBe(false); + }); +}); + +describe('useEscrow — release (#107)', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns the release result on success', async () => { + mockRelease.mockResolvedValueOnce('tx_release_abc'); + + const hook = render(); + const result = await hook.release('escrow-1', 'GA'); + + expect(result).toBe('tx_release_abc'); + expect(mockRelease).toHaveBeenCalledWith({}, { escrowId: 'escrow-1', caller: 'GA' }); + expect(error()).toBeNull(); + expect(loading()).toBe(false); + }); + + it('surfaces the error message and rethrows on failure', async () => { + mockRelease.mockRejectedValueOnce(new Error('unauthorized')); + + const hook = render(); + await expect(hook.release('escrow-1', 'GA')).rejects.toThrow('unauthorized'); + + expect(error()).toBe('unauthorized'); + expect(loading()).toBe(false); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 389e35b..604d7bf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,15 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts', 'src/hooks/index.ts'], + entry: [ + 'src/index.ts', + 'src/hooks/index.ts', + // Subpath build targets so the README's documented imports + // (`@trustflow/sdk/escrow` etc.) resolve against the published package (#100). + 'src/escrow/index.ts', + 'src/wallet/index.ts', + 'src/utils/index.ts', + ], format: ['cjs', 'esm'], dts: true, splitting: false,