Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
21 changes: 9 additions & 12 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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).
34 changes: 0 additions & 34 deletions src/stellar/horizon.ts

This file was deleted.

29 changes: 29 additions & 0 deletions tests/contract-rpc-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
expect(sdk.SorobanRpc).toBeUndefined();
expect(sdk.rpc).toBeDefined();
});
});
74 changes: 74 additions & 0 deletions tests/exports-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -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/<subpath>/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<string, Record<string, string>>;
};
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<string>();
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<string, string> = {
'./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);
}
}
});
});
37 changes: 37 additions & 0 deletions tests/stellar-single-submit.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
99 changes: 99 additions & 0 deletions tests/use-escrow.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 9 additions & 1 deletion tsup.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down