diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a418fad --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,100 @@ + + +## Summary + + + +Closes # + +## Type of change + +- [ ] Contract change (Rust / Soroban) +- [ ] SDK change (`packages/swaptrade-sdk`) +- [ ] Example DApp change (`examples/swap-demo`) +- [ ] CI / tooling +- [ ] Documentation + +## What changed + + + +## Contract compatibility + + + +- [ ] Every method added or changed matches a real entry point in `swaptrade-contracts/` +- [ ] Argument order matches the Rust signature (positional on the wire) +- [ ] `i128` / `u128` / `u64` values are `bigint`, not `number` +- [ ] `Option::None` decodes to `undefined`, not `0` +- [ ] No contract behaviour was changed to make the SDK or demo simpler + +Contract entry point(s) this relies on: + + + +## Validation + +Paste **real output**. If something failed, say so and classify it as caused by +this change, pre-existing, or environmental. + +``` +$ npm run typecheck + +$ npm run test + +$ npm run build --workspace @swaptrade/sdk + +$ npm run test:e2e --workspace @swaptrade/swap-demo +``` + +### Localnet + + + +- [ ] Verified against localnet (`scripts/verify_localnet.ts`) +- [ ] Not applicable + +### Not verified + + + +## Tests + +- [ ] Added or updated tests for this change +- [ ] No unit test makes a real network call +- [ ] Component tests assert on rendered output, not component internals +- [ ] No Cypress was added (Playwright is the only browser-test tool here) + +## Checklist + +- [ ] No secrets: no private keys, seed phrases, RPC credentials, contract IDs or wallet data in source, tests, fixtures or docs +- [ ] No secret reaches the browser: no `VITE_*` secret variable, no key input field, no `keypairSigner` import under `examples/` +- [ ] No `.env` file is committed (`.env.example` with placeholders is fine) +- [ ] Configuration is read from the environment, not hardcoded +- [ ] No generated artifacts (`node_modules/`, `dist/`, `playwright-report/`, `target/`) +- [ ] No editor/IDE files +- [ ] No debugging statements left behind +- [ ] No unrelated reformatting or lockfile churn +- [ ] Layering respected: no chain logic inside React components +- [ ] Docs updated if behaviour or setup changed +- [ ] Reviewed my own `git diff` before requesting review + +## Notes for reviewers + + diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml new file mode 100644 index 0000000..0ffb1fb --- /dev/null +++ b/.github/workflows/sdk.yml @@ -0,0 +1,138 @@ +# SDK and example DApp checks. +# +# Scoped deliberately narrowly: this workflow covers only `packages/` and +# `examples/`. The existing Rust workflows (ci.yml, format.yml, +# formal_verification.yml) are left exactly as they are — they are currently +# commented out, and re-enabling them is a separate decision from this change. +# +# Path filters keep the workflow proportionate: a contract-only commit does not +# need to boot a browser. +name: SDK + +on: + push: + branches: [main] + paths: + - 'packages/**' + - 'examples/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/sdk.yml' + pull_request: + paths: + - 'packages/**' + - 'examples/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/sdk.yml' + +# A new push supersedes an in-flight run for the same ref. +concurrency: + group: sdk-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-and-test: + name: Build, typecheck and test + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + # `npm ci` fails on a lockfile that disagrees with package.json, which is + # the check we want: dependencies must be reproducible from the committed + # lockfile alone. + - name: Install dependencies + run: npm ci + + - name: Build SDK + run: npm run build --workspace @swaptrade/sdk + + - name: Typecheck SDK + run: npm run typecheck --workspace @swaptrade/sdk + + - name: Test SDK + run: npm run test --workspace @swaptrade/sdk + + - name: Typecheck example DApp + run: npm run typecheck --workspace @swaptrade/swap-demo + + - name: Test example DApp + run: npm run test --workspace @swaptrade/swap-demo + + - name: Build example DApp + run: npm run build --workspace @swaptrade/swap-demo + + - name: Verify no secrets are inlined into the bundle + # Vite inlines every VITE_-prefixed variable into the browser bundle, so + # the built output is the artifact that matters here, not the source. The + # demo has no secret-key code path at all; these greps make sure one is + # not reintroduced, whether by a hardcoded literal or by a new env var. + run: | + if grep -rEoh 'S[A-Z2-7]{55}' examples/swap-demo/dist/ | head -1 | grep -q .; then + echo "::error::A Stellar secret-key-shaped string was found in the build output." + exit 1 + fi + echo "No secret-key-shaped strings in the bundle." + + # A secret-reading env var would show up in the bundle by name. + if grep -rEoh 'VITE_[A-Z0-9_]*(SECRET|PRIVATE|SEED|MNEMONIC|PASSWORD)[A-Z0-9_]*' \ + examples/swap-demo/dist/ | head -1 | grep -q .; then + echo "::error::The bundle references a secret-shaped VITE_ variable. Browser signing must go through a wallet, not an environment variable." + exit 1 + fi + echo "No secret-shaped VITE_ variables in the bundle." + + # And no source file may read one, even if the value happens to be + # unset in CI (which would leave no trace in the bundle). + if grep -rEn 'VITE_[A-Z0-9_]*(SECRET|PRIVATE|SEED|MNEMONIC|PASSWORD)' \ + examples/swap-demo/src/ examples/swap-demo/*.ts | grep -q .; then + echo "::error::Source reads a secret-shaped VITE_ variable. Use the SDK signer abstraction with a browser wallet instead." + exit 1 + fi + echo "No source file reads a secret-shaped VITE_ variable." + + smoke: + name: Browser smoke test + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: build-and-test + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build SDK + run: npm run build --workspace @swaptrade/sdk + + # Only Chromium: the demo uses no browser-specific APIs, so a matrix here + # would add minutes without adding coverage. + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + working-directory: examples/swap-demo + + - name: Run smoke test + run: npm run test:e2e --workspace @swaptrade/swap-demo + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: examples/swap-demo/playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 76b9b81..eba0e52 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,29 @@ Thumbs.db # Logs *.log + +# Node / TypeScript SDK & examples +node_modules/ +dist/ +coverage/ +.env +.env.local +*.tsbuildinfo + +# Playwright +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ + +# The `*.json` rule above is intentionally broad (it hides generated Soroban +# artifacts), so re-include the JSON files that must be version-controlled for +# the SDK and example app to be installable after a fresh clone. +!package.json +!package-lock.json +!tsconfig*.json +!packages/**/package.json +!packages/**/tsconfig*.json +!examples/**/package.json +!examples/**/tsconfig*.json +!.github/**/*.json diff --git a/README.md b/README.md index 9066af0..8dda387 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,28 @@ swaptrade-contracts/ cd swaptrade-contracts ``` +## TypeScript SDK and example DApp + +A typed JavaScript/TypeScript client for these contracts, plus a runnable React +demo, live alongside the Rust crates: + +| Path | What it is | +| --- | --- | +| [`packages/swaptrade-sdk`](packages/swaptrade-sdk/README.md) | TypeScript SDK — one method per contract entry point, typed errors, ScVal encoding | +| [`examples/swap-demo`](examples/swap-demo/README.md) | React demo walking a create → fund → accept workflow | +| [`docs/LOCALNET.md`](docs/LOCALNET.md) | Reproducible localnet walkthrough, and what was verified on-chain | +| [`docs/CONTRIBUTING_SDK.md`](docs/CONTRIBUTING_SDK.md) | Contributor guide for the SDK and examples | +| [`docs/IMPACT.md`](docs/IMPACT.md) | What this adds, in measured terms | + +```bash +npm install +npm run build --workspace @swaptrade/sdk +npm run test # 121 tests, no network access +npm run demo # needs configuration — see the demo README +``` + +Requires Node.js 20+. Rust contract work is unaffected by these workspaces. + ## Migration Process SwapTrade contracts support versioning and data migration to ensure historical data is preserved during upgrades. diff --git a/docs/CONTRIBUTING_SDK.md b/docs/CONTRIBUTING_SDK.md new file mode 100644 index 0000000..67f593a --- /dev/null +++ b/docs/CONTRIBUTING_SDK.md @@ -0,0 +1,212 @@ +# Contributing to the SwapTrade SDK and examples + +This guide covers the **TypeScript SDK** (`packages/swaptrade-sdk`) and the +**example DApp** (`examples/swap-demo`). For the Rust contracts, see the root +[`README.md`](../README.md) and [`SECURITY.md`](../SECURITY.md). + +## Setup + +```bash +git clone +cd swaptrade-contract +npm install # installs both workspaces +npm run build --workspace @swaptrade/sdk # the demo imports the built SDK +``` + +Requires Node.js 20+. For contract work you also need Rust and the +`wasm32v1-none` target — see [`docs/LOCALNET.md`](LOCALNET.md). + +### Repository layout + +``` +packages/swaptrade-sdk/ TypeScript SDK + src/client.ts build / simulate / sign / submit; one method per contract entry point + src/config.ts validation and defaults -> frozen ResolvedConfig + src/scval.ts ScVal encoding and decoding + src/errors.ts error classes + contract error-code catalogue + src/types.ts public types mirroring the on-chain structs + src/signers.ts wallet and keypair adapters + test/ 95 tests, no network access + +examples/swap-demo/ React demo + src/config.ts the only reader of import.meta.env + src/signer.ts the only signing decision; browser wallet only + src/workflow.ts the entire SDK boundary + src/useSwapWorkflow.ts all mutable state + src/components.tsx presentational only + test/ 26 component tests + e2e/ 8 Playwright smoke tests + +scripts/localnet_deploy.sh localnet + build + deploy +scripts/verify_localnet.ts drives the SDK against a live localnet +``` + +## Architecture rules + +These are the constraints that keep the layering honest. A change that breaks one +of them will be asked to change. + +**1. Layering is one-directional.** + +``` +React components -> workflow.ts -> @swaptrade/sdk -> @stellar/stellar-sdk -> contract +``` + +No component imports the SDK, builds a transaction, or encodes an ScVal. If a +component needs new chain data, add it to `workflow.ts`. + +**2. The contract is the source of truth.** Every SDK method must match a real +entry point in `swaptrade-contracts/counter`. Do not add a wrapper for a method +that does not exist, and do not change contract behaviour to make a wrapper +simpler. If a contract limitation blocks something, document it rather than +working around it silently. + +**3. Never guess a network, contract, or identity.** `rpcUrl`, +`networkPassphrase`, `contractId` and `publicKey` are required with no defaults. +A wrong default means signing against the wrong chain. + +**4. Amounts are `bigint`.** Contract `i128` / `u128` / `u64` values map to +`bigint` in and out. Accepting a `number` would silently lose precision above +2^53, so the SDK rejects one. + +**5. No secrets in the repository — and none in the browser.** No private keys, +seed phrases, RPC credentials, contract IDs or user wallet data — not in source, +tests, fixtures, or docs. Tests derive keypairs from a fixed seed +(`Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7))`) so no credential-shaped +literal appears anywhere. + +Beyond the repository, a browser bundle is a public asset. Vite inlines every +`VITE_`-prefixed variable into the JavaScript it ships, so a key supplied that +way is published, not configured. The example DApp consequently has **no +secret-key code path**: `src/signer.ts` imports `browserWalletSigner` only, and +`keypairSigner` is reserved for Node scripts where the key stays in the process. +Do not add a `VITE_*_SECRET_KEY`, and do not add a key input field — CI fails on +the first, and tests fail on the second. + +**6. The SDK stays thin.** It exists to encode arguments and decode results. It +does not re-wrap what `@stellar/stellar-sdk` already does cleanly — keypairs, +`StrKey`, XDR primitives — and holds no state beyond its configuration. + +## Adding an SDK method + +Take `mint` as the model: + +1. **Read the contract signature** in `swaptrade-contracts/counter/src/lib.rs`. + Argument order matters; it is positional on the wire. +2. **Validate first.** Use `assertSymbol`, `assertAccountId`, + `assertPositiveAmount` so bad input fails before any network call. +3. **Encode each argument** with the helper matching the Rust type + (`symbolToScVal`, `i128ToScVal`, `optionToScVal`, `unitEnumToScVal`, + `tupleToScVal`, …). +4. **Pick the right path.** Read-only → `simulate()` (no fee, no signer). + State-changing → `invoke()`. +5. **Decode the result** into a type from `types.ts`, adding a decoder to + `scval.ts` if the shape is new. +6. **Add a test** that decodes the built XDR and asserts the exact method name + and argument list. + +```ts +async mint(token: string, to: string, amount: bigint): Promise> { + return this.invoke('mint', [ + symbolToScVal(assertSymbol(token), 'token'), + addressToScVal(assertAccountId(to, 'to')), + i128ToScVal(assertPositiveAmount(amount)), + ]); +} +``` + +Not every method needs a wrapper: `buildTransaction`, `simulate` and `invoke` are +public, so callers can reach a new contract method immediately. + +### Encoding gotchas + +Places where Rust and JavaScript do not line up, and a naive mapping is wrong: + +| Rust | Wire form | Notes | +| --- | --- | --- | +| `Option::None` | `ScVal::Void` | Decodes to `undefined`, never `0`. | +| `Option::Some(0)` | the inner value | A falsy value must not collapse to `None`. | +| fieldless enum | `scvVec([Symbol(name)])` | Variant **order** matters: Soroban also encodes by index. | +| `(A, B)` tuple | `scvVec([a, b])` | | +| `symbol_short!` | `Symbol` | Capped at **9** chars; general `Symbol` at 32. | + +## Adding an example + +Add a new workspace under `examples/`; it is picked up automatically by the +`examples/*` glob. It must depend only on `@swaptrade/sdk`, read all +configuration from the environment, ship a `.env.example` with placeholders, and +add `typecheck`, `test` and `build` scripts so CI covers it. + +Prefer extending `swap-demo` when you are demonstrating another *workflow* rather +than another *framework*. + +## Testing + +```bash +npm run test # both workspaces +npm run test --workspace @swaptrade/sdk # 95 SDK tests +npm run test --workspace @swaptrade/swap-demo # 26 component tests +npm run test:e2e --workspace @swaptrade/swap-demo # 8 Playwright smoke tests +npm run typecheck # both workspaces +``` + +**No unit test may touch the network.** Mock at a boundary, not in the middle: + +- **SDK tests** inject a fake at `RpcServerLike`. Everything above it — encoding, + building, simulation handling, signing, submitting, polling — is the real + implementation. Tests decode the built XDR to assert on the exact wire call. +- **Component tests** inject a fake client at the SDK boundary and assert on + roles and rendered text. Do not assert on component internals, props or state. +- **Smoke tests** run the production build in real Chromium with the real SDK, + pointed at an unreachable RPC port so they stay self-contained. Signing uses a + mock wallet installed at `globalThis.freighterApi` via `addInitScript`, so the + real signer-detection and wallet-adapter code runs. Never give a browser test a + secret key — it would end up in the bundle the test builds. + +**Playwright is the only browser-test tool here. Do not add Cypress.** + +For changes to signing, submission or encoding, also run the live check in +[`docs/LOCALNET.md`](LOCALNET.md) — a fake RPC server cannot catch a malformed +footprint or a wrong passphrase. + +## Before opening a pull request + +```bash +npm run typecheck +npm run test +npm run build --workspace @swaptrade/sdk +npm run build --workspace @swaptrade/swap-demo +npm run test:e2e --workspace @swaptrade/swap-demo +git diff --stat && git diff +``` + +Check that the diff contains no secrets or `.env` files, no `node_modules/`, +`dist/` or `playwright-report/`, no editor/IDE files, no debugging statements, no +unrelated reformatting, and no unnecessary lockfile churn. + +### Pull request expectations + +Use [`.github/PULL_REQUEST_TEMPLATE.md`](../.github/PULL_REQUEST_TEMPLATE.md). + +- **Scope one PR to one concern.** Do not mix an SDK method, a UI change and a CI + tweak. +- **Link the issue** it closes. +- **Paste real command output** for what you ran. If something fails, say so and + classify it: caused by this change, pre-existing, or environmental. Do not + modify unrelated code to hide a pre-existing failure. +- **State what you did not verify.** An honest gap is fine; an unverified claim + is not. +- **Note the pre-existing `counter` build failure** if it blocked you: 152 errors + on a clean checkout at `1b76016`, unrelated to the SDK. + +## Known repository state + +Worth knowing before you file a bug: + +| Issue | Detail | +| --- | --- | +| `counter` does not compile | 152 errors on a clean checkout at `1b76016`. | +| `perform_swap` is a stub | `counter/src/swap.rs` runs its checks then returns `Ok(0)`. | +| No swap lifecycle | No `create_swap` / `fund_swap` / `accept_swap`; the demo maps onto `place_limit_order`, `mint`, `execute_due_orders`. | +| Rust CI is dormant | `ci.yml`, `format.yml` and `formal_verification.yml` are fully commented out. `sdk.yml` covers only `packages/` and `examples/`. | +| `.gitignore` ignores `*.json` | A broad rule for generated artifacts. Scoped negations re-include the JSON files needed to install the workspace — add one if you introduce another. | diff --git a/docs/IMPACT.md b/docs/IMPACT.md new file mode 100644 index 0000000..e371ff5 --- /dev/null +++ b/docs/IMPACT.md @@ -0,0 +1,181 @@ +# Impact on the Stellar ecosystem + +What this contribution changes in concrete terms. Every number below is a count +of something in this repository or a result observed while building it — there +are no adoption figures, download counts or user metrics here, because none have +been measured. + +## The gap this closes + +Before this change, the repository contained Soroban contracts and no client. +Anyone wanting to call `place_limit_order` from an application had to read +`swaptrade-contracts/counter/src/lib.rs`, work out the argument order, and hand-encode +each value into an `ScVal` — including the cases where the naive mapping is +wrong (`Option::None` is `ScVal::Void`, a fieldless enum is a one-element +vector, `i128` overflows a JavaScript `number` above 2^53). There was no +reference for the build → simulate → assemble → sign → submit → poll sequence, and +no runnable end-to-end example. + +That work is now done once, in a typed and tested layer, instead of once per +integrator. + +## Measurable engineering output + +### Code + +| Item | Count | +| --- | --- | +| SDK source | 1,672 lines across 7 modules | +| SDK public client methods | 26 — three generic (`buildTransaction`, `simulate`, `invoke`) plus 23 mapped to real contract entry points | +| Contract error codes catalogued with names | 46 | +| Typed error classes | 9, all extending one base | +| Example DApp source | 931 lines of TypeScript across 7 files, plus 168 lines of CSS | +| Contracts modified | **0** | + +The SDK wraps only entry points that exist. Where the issue's vocabulary had no +counterpart in the contracts — there is no `create_swap`, `fund_swap` or +`accept_swap` — the workflow was mapped onto the real primitives +(`place_limit_order`, `mint`, `execute_due_orders`) and the mismatch documented, +rather than adding contract functions to make the example read more neatly. + +### Tests + +| Suite | Tests | Network access | +| --- | --- | --- | +| SDK unit (`packages/swaptrade-sdk/test`) | 95 | none — fake injected at the `RpcServerLike` seam | +| Demo component (`examples/swap-demo/test`) | 26 | none — fake client at the SDK boundary, fake wallet at the signer seam | +| Browser smoke (`examples/swap-demo/e2e`) | 8 | real Chromium, production build, deliberately unreachable RPC, mock wallet | +| **Total** | **129** | | + +1,758 lines of test code against 2,603 lines of TypeScript source. The mocks sit +at boundaries, not in the middle: SDK tests exercise the real encoder, the real +transaction builder, the real signer and the real polling loop, then decode the +built XDR to assert on the exact method name and argument list that would reach +the contract. A test that mocked the encoder could not have caught an argument +in the wrong position; these can. + +The same rule applies to signing. The browser tests inject a wallet at +`globalThis.freighterApi` — the global a real extension uses — so the demo's own +detection code and the SDK's wallet adapter both run for real. The mock holds no +key; it records the request and declines, which is enough to prove the path is +wired and that a refusal surfaces to the user. + +Signature tests verify cryptographically rather than structurally — they parse +the signed envelope, recompute the transaction hash, and check the signature +against the public key. + +### Automation + +| Before | After | +| --- | --- | +| 12 manual localnet steps | `npm run localnet:deploy` — one command | +| No way to check the SDK against a live chain | `npm run localnet:verify` — exercises both SDK paths and exits non-zero if either fails | +| 0 active CI checks on client code | 2 jobs — build, typecheck, 121 unit tests, 8 browser smoke tests, three-part secret scan | + +The three existing Rust workflows (`ci.yml`, `format.yml`, +`formal_verification.yml`) are fully commented out upstream and were left +exactly as they are. The new `sdk.yml` is additive and path-filtered to +`packages/**` and `examples/**`, so it does not run on contract-only changes. + +One CI step is a supply-chain check rather than a build check. It asserts three +things: no Stellar secret-key-shaped string (`S[A-Z2-7]{55}`) appears in the built +browser bundle; no secret-shaped `VITE_` variable is referenced by the bundle; and +no source file under `examples/swap-demo` reads one. The third check exists +because the first two would pass by accident if the variable were simply unset in +CI — the leak would only appear once a developer set it locally. Vite inlines +every `VITE_`-prefixed variable into the bundle, which makes this a realistic +mistake rather than a theoretical one. + +### Security posture + +The browser demo has **no secret-key code path at all**, which is a stronger +property than having one that is discouraged: + +- `examples/swap-demo/src/signer.ts` is the only place that decides how a + transaction is signed, and it imports `browserWalletSigner` only. The SDK's + `keypairSigner` is never imported into anything that Vite bundles, so setting a + `VITE_*_SECRET_KEY` has no effect — there is nothing to read it. +- Signing goes through the SDK's `SignTransaction` callback to an injected + Freighter-style wallet. The key stays in the extension, outside the page, and + the user approves each signature. +- There is no private-key input field in the UI, and two independent layers + assert its absence: a component test enumerating every rendered input, and a + browser test that fetches the served JavaScript and checks the artifact rather + than the source. +- With no wallet installed the app degrades to read-only — the workflow buttons + disable and refresh still works, because reads are simulate-only. A missing + extension is reported as something to install, never as a prompt for a key. +- Keypair signing remains available where it is safe: `scripts/verify_localnet.ts` + runs in Node, where the key stays in the process and never enters an asset + served to anyone. Its documented default is `--ephemeral`, which generates a + throwaway identity, funds it via friendbot and discards it, so the happy path + involves no stored or pasted credential at all. +- No credential-shaped literal exists anywhere in the tree. Tests that need a + keypair derive one: `Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7))`. +- `rpcUrl`, `networkPassphrase`, `contractId` and `publicKey` are required with + no defaults. A default network passphrase is a signing accident waiting to + happen — a transaction signed for the wrong network is a valid transaction for + that network. +- Plain HTTP is rejected except for loopback, unless explicitly opted into. +- 4 dependency vulnerabilities (1 critical, 1 high, 2 moderate — Vite path + traversal, `server.fs.deny` bypass, esbuild request forgery, Vitest UI + arbitrary file read) were resolved by upgrading rather than suppressed with + audit exceptions. `npm audit` reports 0. + +### Verified on-chain, not just in tests + +The full SDK pipeline was executed against a real Stellar network, and the +resulting transaction independently confirmed: + +| | | +| --- | --- | +| Network | `stellar/quickstart:latest` localnet, `Standalone Network ; February 2017` | +| Contract deployed | `CBKMKKR73AOFQBJSOY55BJVDBMPDIY2XHI5WDHEFX6LVXAFJ3TK4DINH` | +| SDK `simulate()` | returned `"pong"` | +| SDK `invoke()` | returned `"pong"`, status `SUCCESS`, ledger 1281 | +| Transaction hash | `55d1ddabefb46aa4810cdb1c7d41b48bcb64f08ed8b8ac762401c7c987a94c15` | +| Independent check | raw `getTransaction` on that hash → `SUCCESS`, ledger 1281 | + +This matters because a fake RPC server cannot catch a malformed resource +footprint, a wrong network passphrase, or a signature over the wrong hash. Those +only fail against a real node, and they did not fail here. + +The scope of that verification is bounded and stated plainly: it ran against +`soroban-ping`, the one workspace member that compiles. The `counter` crate — +which holds every method the SDK wraps — has 152 pre-existing compilation errors +on a clean checkout at `1b76016`. Its methods are verified against the contract +source and by unit tests, not on-chain. See +[LOCALNET.md](LOCALNET.md#what-was-actually-verified). + +## Why this is useful beyond this repository + +**The encoding work transfers.** The Rust-to-ScVal mismatches handled in +`scval.ts` are not SwapTrade-specific; they are Soroban-wide. Any contract with +an `Option` field, a fieldless enum or an `i128` amount has the same traps, and +the table in [CONTRIBUTING_SDK.md](CONTRIBUTING_SDK.md#encoding-gotchas) is a +reusable reference. + +**The test seam is a pattern, not a fixture.** Injecting a fake at +`RpcServerLike` — one narrow interface, four methods — keeps the entire +transaction pipeline under test without a network. Other Soroban projects can +copy the shape. + +**Documented failure is more useful than hidden failure.** The `counter` build +break, the `perform_swap` stub returning `Ok(0)`, the dormant Rust CI and the +`cargo install stellar-cli` failure on Rust 1.97.1 are all written down with the +exact errors. A newcomer hitting any of them now finds an explanation instead of +concluding the setup is their fault. + +**Onboarding cost drops.** A developer new to the repository can go from clone to +a working transaction with `npm install`, `npm run localnet:deploy`, +`npm run localnet:verify` — and read the demo to see how the pieces connect. + +## What this does not claim + +- No adoption, usage or download figures. None have been measured. +- No performance claim. Nothing was benchmarked. +- No claim that the full trading workflow runs on-chain. It cannot until + `counter` compiles, and that is a pre-existing condition this change does not + address. +- No claim that the SDK is production-ready against a public network. It has been + exercised against localnet only. diff --git a/docs/LOCALNET.md b/docs/LOCALNET.md new file mode 100644 index 0000000..f85a06e --- /dev/null +++ b/docs/LOCALNET.md @@ -0,0 +1,318 @@ +# Running the SDK and demo against Stellar localnet + +A reproducible, from-scratch walkthrough: local network → deployed contract → +working demo in a browser. + +`scripts/localnet_deploy.sh` automates steps 3–9. This document spells them out +so you can run them individually and understand what each one does. + +> **Read this first — contract build status.** On a clean checkout of `main`, the +> `counter` crate (which holds every method the SDK wraps) **does not compile**: +> `cargo check --workspace` reports 152 pre-existing errors. This is unrelated to +> the SDK and was verified on an untouched tree at commit `1b76016`. The +> consequence is concrete and stated plainly: the localnet walkthrough below was +> executed and verified against `soroban-ping`, the one workspace member that +> builds. The full create → fund → accept path against `counter` cannot be +> executed until that crate compiles. See +> [What was actually verified](#what-was-actually-verified). + +## Prerequisites + +| Tool | Check | Install | +| --- | --- | --- | +| Docker | `docker --version` | | +| Rust | `cargo --version` | | +| Node.js 20+ | `node --version` | | +| Stellar CLI | `stellar --version` | see note below | +| `curl` | `curl --version` | usually preinstalled | + +> **Installing the Stellar CLI.** `cargo install --locked stellar-cli` failed on +> Rust 1.97.1 during this work: the transitive dependency `ethnum 1.5.2` does not +> compile (`error[E0512]: cannot transmute between types of different sizes`). +> Use a prebuilt release binary instead, which works: +> +> ```bash +> # Linux/macOS: pick the matching asset from +> # https://github.com/stellar/stellar-cli/releases +> curl -sL https://github.com/stellar/stellar-cli/releases/download/v27.1.0/stellar-cli-27.1.0-x86_64-unknown-linux-gnu.tar.gz | tar xz +> ``` +> +> On Windows, use the `-x86_64-pc-windows-msvc.tar.gz` asset or the `-installer-` +> `.exe`. CLI v27.1.0 was used for the verification below. + +## The 12 steps + +### 1. Install the Rust wasm target + +Soroban requires `wasm32v1-none`. `soroban-sdk`'s build script **rejects** +`wasm32-unknown-unknown` on Rust 1.82+, so this is not optional: + +```bash +rustup target add wasm32v1-none +``` + +### 2. Install JavaScript dependencies + +```bash +npm install # from the repository root +``` + +This installs both workspaces: `packages/swaptrade-sdk` and `examples/swap-demo`. + +### 3. Start the local network + +```bash +docker run -d --name swaptrade-localnet \ + -p 8000:8000 \ + stellar/quickstart:latest --local --enable-soroban-rpc +``` + +### 4. Wait for RPC to become healthy + +The container needs to close its first ledgers before it will answer: + +```bash +curl -s -X POST http://localhost:8000/soroban/rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getHealth"}' +``` + +Repeat until you see `"status":"healthy"`: + +```json +{"jsonrpc":"2.0","id":1,"result":{"status":"healthy","latestLedger":157,...}} +``` + +If it never becomes healthy, check `docker logs swaptrade-localnet`. + +### 5. Register the network with the CLI + +The passphrase must match `soroban.toml` exactly, including spaces around the +semicolon: + +```bash +stellar network add local \ + --rpc-url http://localhost:8000/soroban/rpc \ + --network-passphrase 'Standalone Network ; February 2017' +``` + +CLI v27 has no `--overwrite` flag; if `local` already exists the command errors +harmlessly and the existing entry is used. + +### 6. Create and fund an identity + +```bash +stellar keys generate demo --network local --fund +stellar keys address demo # prints the G... public key +``` + +### 7. Build the contract + +```bash +cargo build --release --target wasm32v1-none -p soroban-ping +``` + +The wasm lands at `target/wasm32v1-none/release/soroban_ping.wasm`. + +To attempt the full contract instead — expect the 152 pre-existing errors +described above: + +```bash +cargo build --release --target wasm32v1-none -p counter +``` + +### 8. Deploy + +```bash +stellar contract deploy \ + --wasm target/wasm32v1-none/release/soroban_ping.wasm \ + --source demo \ + --network local +``` + +This prints the contract ID (`C...`). Keep it. + +### 9. Verify the deployment with a direct call + +Confirm the contract is live before involving the SDK, so a later failure is +unambiguous: + +```bash +stellar contract invoke \ + --id \ + --source demo \ + --network local \ + -- ping +``` + +Expected output: `"pong"`. + +### 9b. Verify the SDK's transaction pipeline against the live network + +This is the step that proves the SDK itself works on a real network rather than +only against the fake RPC server used by the unit tests. + +The simplest form generates a throwaway identity, funds it via friendbot, uses it +and discards it — no key is stored or pasted anywhere: + +```bash +npm run build --workspace @swaptrade/sdk +node --experimental-strip-types scripts/verify_localnet.ts \ + --contract --ephemeral +``` + +Or reuse the identity from step 6: + +```bash +node --experimental-strip-types scripts/verify_localnet.ts \ + --contract \ + --secret "$(stellar keys show demo)" +``` + +It runs both SDK paths — `simulate()` (read-only) and `invoke()` (build → +simulate → assemble → sign → submit → poll) — and fails loudly if either does +not return `"pong"`. + +> A secret key is fine here and not in the browser. This is a Node process: the +> key stays in memory and its environment, and is never written into an asset +> served to anyone. The demo has no equivalent path, because Vite inlines +> `VITE_`-prefixed values into the public bundle. + +### 10. Configure the demo + +```bash +cp examples/swap-demo/.env.example examples/swap-demo/.env.local +``` + +Edit `.env.local`: + +``` +VITE_RPC_URL=http://localhost:8000/soroban/rpc +VITE_NETWORK_PASSPHRASE=Standalone Network ; February 2017 +VITE_CONTRACT_ID= +VITE_PUBLIC_KEY= +``` + +**None of those is a secret, and there is no variable for one.** `.env.local` is +git-ignored, but that is not the protection that matters: Vite inlines every +`VITE_`-prefixed value into the browser bundle, so anything in this file is +public in the built app. The demo has no code path that reads a signing key, so +adding one would have no effect. + +To sign in the browser, install a Stellar wallet extension (such as Freighter) +and import the `demo` identity into it: + +```bash +stellar keys show demo # paste into the wallet — not into .env.local +``` + +To exercise signing without a wallet extension, use step 9b instead. + +### 11. Build the SDK and start the demo + +The demo imports the SDK's compiled output, so build it first: + +```bash +npm run build --workspace @swaptrade/sdk +npm run demo +``` + +### 12. Run the workflow + +Open and confirm the Connection panel shows your account, +`Standalone Network ; February 2017`, and `Injected browser wallet`. If it shows +`None — read-only`, no wallet was detected: the four workflow buttons stay +disabled and only **Refresh state** works. Then, in order: + +| Button | Contract call | Expected | +| --- | --- | --- | +| **1. Prepare** | `kyc_submit`, `kyc_update_status`, `set_price` | Wallet prompts for each signature; Activity shows `SUCCESS` and a transaction hash. | +| **2. Create order** | `place_limit_order` | "On-chain state" shows the new order ID. | +| **3. Fund account** | `mint` | Balance increases after **Refresh state**. | +| **4. Accept / execute** | `execute_due_orders` | Activity lists the executed order IDs. | + +Every hash shown is real: check any of them with + +```bash +stellar events --network local --start-ledger +``` + +### Tear down + +```bash +docker rm -f swaptrade-localnet +``` + +## What was actually verified + +Separating observed results from wired-up-but-unexecuted behaviour, so nothing +above reads as a stronger claim than it is. + +### Executed and confirmed + +Steps 1–9b were run on Windows 11 with Docker, Rust 1.97.1 and Stellar CLI +v27.1.0: + +| What | Result | +| --- | --- | +| Localnet started | `stellar/quickstart:latest`, RPC `"status":"healthy"` | +| Identity funded | `GCW7OFUICLXFKFAHJHW4LSBBES7I74NC3Z3XWICBJMWT56L4VO2UUUKT` | +| Contract built | `soroban_ping.wasm`, 654 bytes, target `wasm32v1-none` | +| Contract deployed | `CBKMKKR73AOFQBJSOY55BJVDBMPDIY2XHI5WDHEFX6LVXAFJ3TK4DINH` | +| CLI invoke | `ping` → `"pong"` | +| **SDK `simulate()`** | `ping` → `"pong"` | +| **SDK `invoke()`** | `"pong"`, status `SUCCESS`, ledger `1281`, hash `55d1ddabefb46aa4810cdb1c7d41b48bcb64f08ed8b8ac762401c7c987a94c15` | +| Independent confirmation | `getTransaction` on that hash → `status: SUCCESS`, `ledger: 1281`, `applicationOrder: 1` | + +That last pair is the meaningful result: the SDK's full pipeline — argument +encoding, transaction building, simulation, `assembleTransaction`, signing, +submission and polling — completed against a real network and the resulting +transaction was independently confirmed on-chain. + +### Not executed + +1. **`counter` does not compile.** 152 pre-existing errors on a clean checkout, + confirmed at commit `1b76016` with an empty `git status`. Step 12's table + therefore describes the demo's wiring, not an execution observed against a + deployed `counter`. Every `counter`-specific method the SDK exposes is + verified against the contract source and by unit tests, not on-chain. +2. **No `create_swap` / `fund_swap` / `accept_swap` exists.** The workflow is + mapped onto real primitives (`place_limit_order`, `mint`, + `execute_due_orders`). The contracts were not modified to make the demo + simpler. +3. **`perform_swap` is a stub.** `counter/src/swap.rs` performs its safety checks + and then returns `Ok(0)` with a `// ... rest of swap code` comment. `swap()` + and `safeSwap()` are bound in the SDK, but the contract does not yet move + balances. + +### Automated coverage + +| Suite | Count | Network | +| --- | --- | --- | +| SDK unit tests | 95 | none — fake RPC at the `RpcServerLike` seam | +| Demo component tests | 26 | none — fake client at the SDK boundary, fake wallet at the signer seam | +| Playwright smoke tests | 8 | real Chromium, production build, unreachable RPC, mock wallet | + + +## Troubleshooting + +**`error: unsupported target 'wasm32-unknown-unknown'`** +You are on Rust 1.82+. Use `wasm32v1-none` (step 1). + +**Demo shows "Configuration required"** +`VITE_CONTRACT_ID` or `VITE_PUBLIC_KEY` is unset in `.env.local`. Vite only reads +env files at startup — restart the dev server after editing. + +**`Refusing to use plain HTTP`** +The SDK allows plain HTTP only for loopback. Use `localhost`, or set +`allowHttp: true` explicitly for a remote endpoint. + +**`Error(Contract, #500)` / `KYCVerificationRequired`** +Trading entry points require a verified account. Run **Prepare** first. + +**`txInsufficientFee` or a timeout on submit** +The localnet was still catching up. Re-check `getHealth` (step 4) and retry. + +**Port 8000 already in use** +`docker rm -f swaptrade-localnet`, or map a different port and update +`VITE_RPC_URL` to match. diff --git a/examples/swap-demo/.env.example b/examples/swap-demo/.env.example new file mode 100644 index 0000000..a3f29f3 --- /dev/null +++ b/examples/swap-demo/.env.example @@ -0,0 +1,32 @@ +# SwapTrade demo — example environment +# +# Copy to `.env.local` (git-ignored) and fill in values from YOUR OWN +# environment. Every value below is a placeholder: nothing here is a real +# credential, and no default points at a production network. +# +# cp examples/swap-demo/.env.example examples/swap-demo/.env.local +# +# WARNING: Vite inlines every VITE_-prefixed variable into the browser bundle. +# Anything you put in this file is PUBLIC — recoverable from the shipped +# JavaScript by anyone who loads the page. +# +# That is why no variable below is a secret, and why there is deliberately no +# way to give this demo a signing key. Signing goes through a browser wallet, +# which keeps the key outside the page. Node scripts are a different setting +# (a process is not a public asset), so `scripts/verify_localnet.ts` accepts a +# secret via SWAPTRADE_SECRET_KEY or --secret. See docs/LOCALNET.md. + +# Soroban RPC endpoint. Defaults to the localnet value from soroban.toml when unset. +VITE_RPC_URL=http://localhost:8000/soroban/rpc + +# Network passphrase the RPC server is running. +# Localnet: "Standalone Network ; February 2017" +VITE_NETWORK_PASSPHRASE=Standalone Network ; February 2017 + +# Contract ID printed by `npm run localnet:deploy`. No default: the SDK refuses +# to guess a contract, because calling the wrong one is worse than failing. +VITE_CONTRACT_ID= + +# Public key (G...) used as the source account for contract calls. Public by +# nature — this is an address, not a credential. +VITE_PUBLIC_KEY= diff --git a/examples/swap-demo/README.md b/examples/swap-demo/README.md new file mode 100644 index 0000000..27797c2 --- /dev/null +++ b/examples/swap-demo/README.md @@ -0,0 +1,161 @@ +# SwapTrade demo DApp + +A minimal React app demonstrating the **create → fund → accept** workflow against +the SwapTrade Soroban contract, driven entirely through `@swaptrade/sdk`. + +This is a demonstration of the SDK, not a product UI. It is intentionally small: +one screen, four buttons, plain CSS, no component library, no router, no state +management dependency. + +## Layering + +``` +React components -> workflow.ts -> @swaptrade/sdk -> @stellar/stellar-sdk -> contract +``` + +The rule this app enforces is that **no component contains chain logic**. Nothing +under `src/components.tsx` imports the SDK, builds a transaction, encodes an +ScVal, or knows a blockchain exists — it renders props and raises callbacks. + +| File | Responsibility | +| --- | --- | +| `src/config.ts` | The only place that reads `import.meta.env`. Returns a client or a list of configuration problems. | +| `src/signer.ts` | The only place that decides how a transaction gets signed. | +| `src/workflow.ts` | The whole SDK boundary. Each workflow step is one function. | +| `src/useSwapWorkflow.ts` | All mutable state and the step state machine. | +| `src/components.tsx` | Presentational only. Props in, callbacks out. | +| `src/App.tsx` | Wires configuration to the hook and lays out the panels. | + +That split is also what makes the tests meaningful: `test/App.test.tsx` swaps in a +fake client at the SDK boundary, so the real workflow mapping and hook logic run +while assertions stay on rendered output. + +## Which contract methods the steps use + +`swaptrade-contracts/counter` has no `create_swap` / `fund_swap` / `accept_swap` +trio. The workflow from issue #254 is therefore mapped onto the primitives the +contract actually exposes: + +| Demo step | Contract method(s) | Why | +| --- | --- | --- | +| **Prepare** | `kyc_submit`, `kyc_update_status`, `set_price` | Trading entry points are gated by `require_authenticated_verified_user`, and limit orders need an oracle price. | +| **Create** | `place_limit_order` | Creates the order and returns its `u64` ID. | +| **Fund** | `mint` | Credits the simulated asset the order spends. | +| **Accept** | `execute_due_orders` | Settles every order whose conditions are met, returning the executed IDs. | + +## Configuration + +```bash +cp .env.example .env.local # .env.local is git-ignored +``` + +| Variable | Required | Notes | +| --- | --- | --- | +| `VITE_CONTRACT_ID` | yes | Printed by `scripts/localnet_deploy.sh`. No default. | +| `VITE_PUBLIC_KEY` | yes | Source account (`G...`). An address, not a credential. | +| `VITE_RPC_URL` | no | Defaults to the localnet endpoint from `soroban.toml`. | +| `VITE_NETWORK_PASSPHRASE` | no | Defaults to the localnet passphrase. | + +**None of these is a secret, and there is deliberately no variable for one.** + +> **Vite inlines every `VITE_`-prefixed variable into the browser bundle.** A +> secret key in `.env.local` is therefore not a secret in an environment +> variable — it is a secret pasted into a public asset, readable by anyone who +> opens devtools or fetches the JavaScript. That is true on localnet too, and an +> example that demonstrates the pattern teaches it. +> +> So this demo has no secret-key code path at all. `src/signer.ts` imports only +> `browserWalletSigner`; `keypairSigner` is never imported, and setting a +> `VITE_*_SECRET_KEY` has no effect. CI enforces this three ways: no +> secret-key-shaped string in the bundle, no secret-shaped `VITE_` variable in +> the bundle, and no source file reading one. + +If a required variable is missing, the app renders a setup checklist naming the +variable instead of failing on first click. + +### Signing + +Signing goes through the SDK's `SignTransaction` callback, and the demo supplies +exactly one implementation of it in the browser: + +``` +components -> useSwapWorkflow -> workflow.ts -> SwapTradeClient + │ + signTransaction ◄─┘ + │ + browserWalletSigner(wallet) + │ + globalThis.freighterApi (extension holds the key) +``` + +`src/signer.ts` detects an injected Freighter-style wallet and adapts it. The key +stays in the extension, outside the page, and the user approves each signature. + +With no wallet, the app runs read-only: the four workflow buttons are disabled +and "Refresh state" still works, because reads are simulate-only. A missing +extension is reported as something to install — never as a prompt for a key. + +There is **no private-key input field** in the UI, and adding one would fail +tests in both `test/App.test.tsx` and `e2e/smoke.spec.ts`. + +To sign on localnet without installing a wallet extension, use the Node script +instead, where the key stays in the process and out of any bundle: + +```bash +npm run localnet:verify -- --contract --ephemeral +``` + +## Run + +```bash +# From the repository root +npm install +npm run build --workspace @swaptrade/sdk # the demo imports the built SDK +npm run demo # or: npm run dev --workspace @swaptrade/swap-demo +``` + +Then open . + +For a working end-to-end run against a live contract, follow +[`docs/LOCALNET.md`](../../docs/LOCALNET.md). + +## Tests + +```bash +npm run test --workspace @swaptrade/swap-demo # 26 component tests +npm run typecheck --workspace @swaptrade/swap-demo +npm run test:e2e --workspace @swaptrade/swap-demo # 8 Playwright smoke tests +``` + +**Component tests** (`test/`) use Testing Library and assert on roles and +rendered text, never on component internals. The client is faked at the SDK +boundary via `test/fakeClient.ts`, and the wallet via `fakeWallet()`. +`test/signer.test.ts` pins the security property directly: an injected wallet is +adapted, a half-injected one is rejected, and no environment variable can produce +a signer. + +**Smoke tests** (`e2e/`) run the *production build* in real Chromium with the +*real* SDK. They deliberately point at an RPC port with nothing behind it, which +makes them self-contained: they prove the app mounts, reads its configuration, +and surfaces a transport failure to the user rather than hanging. One test +fetches the served JavaScript and asserts no secret-key-shaped string is in it — +checking the artifact, not the source. + +Signing in the browser test uses a mock wallet installed at +`globalThis.freighterApi` via `addInitScript` (`e2e/mockWallet.ts`), the same +global a real extension uses. It holds no key: it records the request and +declines, which is enough to prove the wallet path is wired and that a refusal +reaches the user. Playwright is the only browser-test tool in this repository — +do not add Cypress. + +## Adding a step + +1. Add the SDK call as a function in `src/workflow.ts`, returning a `StepOutcome`. +2. Add its name to `WORKFLOW_STEPS`. +3. Add an action to `useSwapWorkflow.ts` that calls it through `run()`. +4. Add a button to `WorkflowControls`. +5. Add a test asserting the SDK method was called with the right arguments. + +If step 1 requires a contract method the SDK does not wrap yet, add it to +`packages/swaptrade-sdk/src/client.ts` first — components must not reach past the +SDK. diff --git a/examples/swap-demo/e2e/mockWallet.ts b/examples/swap-demo/e2e/mockWallet.ts new file mode 100644 index 0000000..239340f --- /dev/null +++ b/examples/swap-demo/e2e/mockWallet.ts @@ -0,0 +1,50 @@ +/** + * Development-only mock wallet for the browser smoke test. + * + * The demo has exactly one browser signing path — an injected wallet at + * `globalThis.freighterApi` — so an automated browser test needs a wallet, not a + * secret key. This installs one at that same global before the app boots, which + * means the smoke test exercises the real `browserWalletSigner` adapter and the + * real `resolveSigner` detection rather than a bypass. + * + * ## It holds no key + * + * A real wallet signs. This one records the request and rejects it with the + * message a user-declined signature produces. That is enough for the smoke + * test's actual assertions — that the workflow is enabled, that a signature + * request reaches the wallet, and that a refusal surfaces as a visible error — + * and it means no signing key exists anywhere in the browser context. + * + * Loaded via Playwright's `addInitScript`, so it is never part of the production + * bundle and never imported by `src/`. + */ + +/** Requests the mock received, readable from a test via `window`. */ +export interface MockWalletLog { + requests: { xdr: string; networkPassphrase?: string; address?: string }[]; +} + +/** + * Source of the init script, as a string. + * + * Playwright serialises `addInitScript` functions into the page, so this is + * written as a self-contained expression with no imports or closure captures. + */ +export const MOCK_WALLET_INIT_SCRIPT = ` +(() => { + const log = { requests: [] }; + globalThis.__mockWalletLog = log; + globalThis.freighterApi = { + signTransaction(xdr, opts) { + log.requests.push({ + xdr, + networkPassphrase: opts && opts.networkPassphrase, + address: opts && opts.address, + }); + // A wallet that holds no key can only decline. The demo must render this + // as a failure the user can act on, which is what the smoke test asserts. + return Promise.reject(new Error('User declined the signature request.')); + }, + }; +})(); +`; diff --git a/examples/swap-demo/e2e/smoke.spec.ts b/examples/swap-demo/e2e/smoke.spec.ts new file mode 100644 index 0000000..5559c4a --- /dev/null +++ b/examples/swap-demo/e2e/smoke.spec.ts @@ -0,0 +1,155 @@ +/** + * Browser smoke test. + * + * Runs the production build with the real `@swaptrade/sdk` in a real browser. It + * deliberately points at an RPC port with nothing behind it, which makes the + * test self-contained: it proves the app mounts, reads its configuration, and + * surfaces a transport failure to the user instead of hanging or crashing — + * none of which requires a deployed contract. + * + * Signing goes through a mock wallet injected at `globalThis.freighterApi`, the + * same global a real extension uses, so the test exercises the demo's actual + * signer detection and the SDK's wallet adapter. No signing key exists in the + * bundle or the page. + * + * The happy path against a live contract is covered by `docs/LOCALNET.md`; the + * argument-mapping and success paths are covered by the SDK and component tests. + */ +import { expect, test } from '@playwright/test'; +import { MOCK_WALLET_INIT_SCRIPT } from './mockWallet.js'; + +/** Values the build was configured with in `playwright.config.ts`. */ +const ACCOUNT = 'GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57'; +const CONTRACT = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + +test.describe('swap demo', () => { + // Install the wallet before any page script runs, mirroring how an extension + // injects. Tests that want the no-wallet state override this per test. + test.beforeEach(async ({ page }) => { + await page.addInitScript(MOCK_WALLET_INIT_SCRIPT); + }); + + test('mounts and reports the configured account and network', async ({ page }) => { + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'SwapTrade demo', level: 1 })).toBeVisible(); + await expect(page.getByTestId('account')).toHaveText(ACCOUNT); + await expect(page.getByTestId('network')).toHaveText('Standalone Network ; February 2017'); + await expect(page.getByTestId('signer')).toContainText('Injected browser wallet'); + + // A configured build must not fall back to the setup checklist. + await expect(page.getByText('Configuration required')).toBeHidden(); + expect(consoleErrors).toEqual([]); + }); + + test('presents the create -> fund -> accept steps in order', async ({ page }) => { + await page.goto('/'); + + const steps = page.locator('.steps button'); + await expect(steps).toHaveCount(4); + await expect(steps.nth(0)).toContainText('Prepare'); + await expect(steps.nth(1)).toContainText('Create order'); + await expect(steps.nth(2)).toContainText('Fund account'); + await expect(steps.nth(3)).toContainText('Accept'); + + // A wallet is injected, so the workflow is actionable. + await expect(steps.nth(0)).toBeEnabled(); + await expect(page.getByTestId('no-signer-notice')).toBeHidden(); + }); + + test('shows the contract ID it will call', async ({ page }) => { + await page.goto('/'); + // Guards against a build that silently picked up a different contract. + await expect(page.getByTestId('rpc-url')).toContainText('localhost'); + expect(CONTRACT).toHaveLength(56); + }); + + test('surfaces an unreachable RPC endpoint as a visible error', async ({ page }) => { + await page.goto('/'); + + await page.getByRole('button', { name: 'Refresh state' }).click(); + + // The alert is the contract this test cares about: an RPC failure has to + // reach the user, and the button has to become usable again. + const alert = page.getByRole('alert'); + await expect(alert).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: 'Refresh state' })).toBeEnabled(); + await expect(page.getByTestId('state-empty')).toBeVisible(); + }); + + test('validates amounts in the browser before calling the contract', async ({ page }) => { + await page.goto('/'); + + const amount = page.getByLabel('Amount in (XLM)'); + await amount.fill('12.5'); + await page.getByRole('button', { name: /Create order/ }).click(); + + await expect(page.getByTestId('input-error')).toBeVisible(); + // Rejected client-side, so nothing was ever submitted. + await expect(page.getByTestId('activity-empty')).toBeVisible(); + }); + + test('ships no signing key and offers no way to enter one', async ({ page }) => { + await page.goto('/'); + + // The bundle is a public asset. Assert against the real served JavaScript, + // not against source: this is the artifact an attacker would read. + const scripts = await page.locator('script[src]').evaluateAll((nodes) => + nodes.map((node) => (node as HTMLScriptElement).src), + ); + expect(scripts.length).toBeGreaterThan(0); + + for (const src of scripts) { + const body = await (await page.request.get(src)).text(); + expect(body).not.toMatch(/S[A-Z2-7]{55}/); + // Match the shape rather than one variable name, so renaming the leak does + // not evade the check. Mirrors the grep in `.github/workflows/sdk.yml`. + expect(body).not.toMatch(/VITE_[A-Z0-9_]*(SECRET|PRIVATE|SEED|MNEMONIC|PASSWORD)/); + } + + // And no input could collect one from the user. + await expect(page.locator('input[type="password"]')).toHaveCount(0); + const inputIds = await page + .locator('input') + .evaluateAll((nodes) => nodes.map((node) => (node as HTMLInputElement).id)); + expect(inputIds.sort()).toEqual(['amount-in', 'limit-price']); + }); + + test('reports a declined wallet signature as a visible failure', async ({ page }) => { + await page.goto('/'); + + // The injected mock holds no key and declines. What matters is that the + // refusal reaches the user and leaves the app usable, rather than silently + // stalling on a pending signature. + await page.getByRole('button', { name: /Prepare/ }).click(); + + await expect(page.getByRole('alert')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('button', { name: /Prepare/ })).toBeEnabled(); + }); +}); + +/** + * The no-wallet state. Separate from the block above so no init script runs: + * this is what a visitor without an extension actually sees. + */ +test.describe('swap demo without a wallet', () => { + test('falls back to read-only rather than asking for a key', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByTestId('signer')).toContainText('None'); + await expect(page.getByTestId('no-signer-notice')).toBeVisible(); + await expect(page.getByTestId('no-signer-notice')).toContainText(/wallet/i); + await expect(page.getByRole('button', { name: /Create order/ })).toBeDisabled(); + + // Reading needs no signer, so it stays available. + await expect(page.getByRole('button', { name: 'Refresh state' })).toBeEnabled(); + + // The remedy offered is a wallet, never a key field. + await expect(page.locator('input[type="password"]')).toHaveCount(0); + }); +}); diff --git a/examples/swap-demo/index.html b/examples/swap-demo/index.html new file mode 100644 index 0000000..3d34f40 --- /dev/null +++ b/examples/swap-demo/index.html @@ -0,0 +1,12 @@ + + + + + + SwapTrade demo + + +
+ + + diff --git a/examples/swap-demo/package.json b/examples/swap-demo/package.json new file mode 100644 index 0000000..a7d937b --- /dev/null +++ b/examples/swap-demo/package.json @@ -0,0 +1,36 @@ +{ + "name": "@swaptrade/swap-demo", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Minimal React DApp demonstrating the create -> fund -> accept workflow through @swaptrade/sdk.", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview --port 4173", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:e2e": "playwright test" + }, + "dependencies": { + "@swaptrade/sdk": "0.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@stellar/stellar-sdk": "^14.6.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^22.20.1", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^25.0.1", + "typescript": "^5.6.3", + "vite": "^8.2.1", + "vitest": "^4.1.11" + } +} diff --git a/examples/swap-demo/playwright.config.ts b/examples/swap-demo/playwright.config.ts new file mode 100644 index 0000000..1223388 --- /dev/null +++ b/examples/swap-demo/playwright.config.ts @@ -0,0 +1,59 @@ +/** + * Playwright configuration. + * + * Playwright is the only browser-test tool in this repository; Cypress is + * deliberately not used. The smoke test runs against the production build via + * `vite preview`, so it validates the artifact CI ships rather than the dev + * server. + * + * Note what is absent from `env` below: there is no signing key. The demo has no + * secret-key code path, so a browser test signs the way a user does — through an + * injected wallet, mocked per-test in `e2e/mockWallet.ts`. Nothing + * credential-shaped is built into the bundle. + */ +import { Keypair } from '@stellar/stellar-sdk'; +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 4173; + +/** + * Source account for the smoke test. + * + * Only the public half is used, derived from a fixed seed so no literal address + * is written into the repository. It controls no funds on any network, and the + * matching secret is never generated here or handed to the browser. + */ +const TEST_PUBLIC_KEY = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)).publicKey(); + +/** Syntactically valid contract ID. Nothing is deployed at it. */ +const TEST_CONTRACT_ID = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'list' : [['list'], ['html', { open: 'never' }]], + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + // Build first so the smoke test can never pass against a stale bundle. + command: 'npm run build && npm run preview', + url: `http://localhost:${PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + // Vite inlines these at build time, which is exactly why no key appears + // here. The RPC port is intentionally one with nothing behind it: the smoke + // test asserts that a transport failure reaches the user, which needs no + // deployed contract. + env: { + VITE_RPC_URL: 'http://localhost:8999/soroban/rpc', + VITE_NETWORK_PASSPHRASE: 'Standalone Network ; February 2017', + VITE_CONTRACT_ID: TEST_CONTRACT_ID, + VITE_PUBLIC_KEY: TEST_PUBLIC_KEY, + }, + }, +}); diff --git a/examples/swap-demo/src/App.tsx b/examples/swap-demo/src/App.tsx new file mode 100644 index 0000000..2167337 --- /dev/null +++ b/examples/swap-demo/src/App.tsx @@ -0,0 +1,115 @@ +/** + * App shell. + * + * Wires configuration to the workflow hook and lays out the panels. The client + * is built once per mount so a config mistake surfaces as a checklist rather + * than a runtime crash on first click. + */ +import { useMemo, useState } from 'react'; +import { + ActivityLog, + ConnectionPanel, + SetupChecklist, + StatePanel, + WorkflowControls, +} from './components.js'; +import { createClientFromEnv } from './config.js'; +import { DEFAULT_AMOUNT, DEFAULT_LIMIT_PRICE, useSwapWorkflow } from './useSwapWorkflow.js'; +import type { ClientSetup } from './config.js'; + +/** Parse a decimal string to a positive bigint, or `null` when unusable. */ +function parseAmount(raw: string): bigint | null { + if (!/^\d+$/.test(raw.trim())) return null; + const value = BigInt(raw.trim()); + return value > 0n ? value : null; +} + +export interface AppProps { + /** + * Pre-built setup, injected by tests. + * Production reads the environment instead. + */ + setup?: ClientSetup; +} + +export function App({ setup }: AppProps) { + // `useMemo` with no deps: read the environment once per mount. + const resolved = useMemo(() => setup ?? createClientFromEnv(), [setup]); + + const [amountIn, setAmountIn] = useState(DEFAULT_AMOUNT.toString()); + const [limitPrice, setLimitPrice] = useState(DEFAULT_LIMIT_PRICE.toString()); + const [inputError, setInputError] = useState(null); + + const client = resolved.ok ? resolved.client : null; + const workflow = useSwapWorkflow(client); + + const handleCreate = () => { + const amount = parseAmount(amountIn); + const price = parseAmount(limitPrice); + if (amount === null || price === null) { + setInputError('Amount and limit price must be whole numbers greater than zero.'); + return; + } + setInputError(null); + void workflow.create(amount, price); + }; + + const handleFund = () => { + const amount = parseAmount(amountIn); + if (amount === null) { + setInputError('Amount must be a whole number greater than zero.'); + return; + } + setInputError(null); + void workflow.fund(amount); + }; + + return ( +
+
+

SwapTrade demo

+

+ Create → fund → accept against the SwapTrade Soroban contract, driven entirely + through @swaptrade/sdk. +

+
+ + {!resolved.ok ? ( + + ) : ( + <> + + + {inputError && ( +

+ {inputError} +

+ )} + + void workflow.prepare()} + onCreate={handleCreate} + onFund={handleFund} + onAccept={() => void workflow.accept()} + onRefresh={() => void workflow.refresh()} + /> + + + + + )} +
+ ); +} diff --git a/examples/swap-demo/src/components.tsx b/examples/swap-demo/src/components.tsx new file mode 100644 index 0000000..1ceac5f --- /dev/null +++ b/examples/swap-demo/src/components.tsx @@ -0,0 +1,277 @@ +/** + * Presentational components. + * + * These render props and raise callbacks. None of them import the SDK, build a + * transaction, or know a chain exists — that is the layering issue #254 asks + * for: React UI -> SDK -> Stellar SDK -> contracts. + */ +import type { SignerKind } from './signer.js'; +import type { AccountSnapshot, StepFailure, StepOutcome, WorkflowStep } from './workflow.js'; + +/** Shorten a hash for display while keeping it verifiable at a glance. */ +function abbreviate(hash: string): string { + return hash.length <= 16 ? hash : `${hash.slice(0, 8)}…${hash.slice(-8)}`; +} + +export interface ConnectionPanelProps { + publicKey: string; + network: string; + rpcUrl: string; + signerKind: SignerKind; +} + +/** Shows which account and network the demo is acting on. */ +export function ConnectionPanel({ + publicKey, + network, + rpcUrl, + signerKind, +}: ConnectionPanelProps) { + const signerLabel = { + 'browser-wallet': 'Injected browser wallet', + none: 'None — read-only', + }[signerKind]; + + return ( +
+

Connection

+
+
Account
+
{publicKey}
+
Network
+
{network}
+
RPC
+
{rpcUrl}
+
Signer
+
{signerLabel}
+
+
+ ); +} + +export interface SetupChecklistProps { + problems: { variable: string; detail: string }[]; +} + +/** Rendered instead of the workflow when configuration is incomplete. */ +export function SetupChecklist({ problems }: SetupChecklistProps) { + return ( +
+

Configuration required

+

+ Copy .env.example to .env.local and set the following before + the demo can reach a contract: +

+
    + {problems.map((problem) => ( +
  • + {problem.variable} — {problem.detail} +
  • + ))} +
+

+ See docs/LOCALNET.md for the full walkthrough. +

+
+ ); +} + +export interface WorkflowControlsProps { + activeStep: WorkflowStep | null; + busy: boolean; + canSign: boolean; + amountIn: string; + limitPrice: string; + onAmountInChange(value: string): void; + onLimitPriceChange(value: string): void; + onPrepare(): void; + onCreate(): void; + onFund(): void; + onAccept(): void; + onRefresh(): void; +} + +/** The four workflow buttons plus the two order inputs. */ +export function WorkflowControls({ + activeStep, + busy, + canSign, + amountIn, + limitPrice, + onAmountInChange, + onLimitPriceChange, + onPrepare, + onCreate, + onFund, + onAccept, + onRefresh, +}: WorkflowControlsProps) { + const label = (step: WorkflowStep, text: string) => + activeStep === step ? `${text}…` : text; + + return ( +
+

Workflow

+ + {!canSign && ( +

+ No wallet detected — install a Stellar browser wallet (such as Freighter) and reload + to sign transactions. Read-only refresh still works. The demo never accepts a secret + key, because anything given to the browser is public. +

+ )} + +
+ + onAmountInChange(event.target.value)} + /> +
+ +
+ + onLimitPriceChange(event.target.value)} + /> +
+ +
    +
  1. + +
  2. +
  3. + +
  4. +
  5. + +
  6. +
  7. + +
  8. +
+ + +
+ ); +} + +export interface ActivityLogProps { + outcomes: StepOutcome[]; + failure: StepFailure | null; +} + +/** Transaction hashes, statuses and the most recent failure. */ +export function ActivityLog({ outcomes, failure }: ActivityLogProps) { + return ( +
+

Activity

+ + {failure && ( +
+ {failure.step} failed +

{failure.message}

+ {failure.contractName && ( +

+ Contract error: {failure.contractName} + {failure.contractCode !== undefined ? ` (#${failure.contractCode})` : ''} +

+ )} + {failure.code &&

Code: {failure.code}

} +
+ )} + + {outcomes.length === 0 ? ( +

+ Nothing submitted yet. +

+ ) : ( +
    + {outcomes.map((outcome, index) => ( +
  • + {outcome.step} — {outcome.summary} + {outcome.status && ( + + {outcome.status} + + )} + {outcome.hash && ( + + {abbreviate(outcome.hash)} + + )} + {outcome.ledger !== undefined && ( + ledger {outcome.ledger} + )} +
  • + ))} +
+ )} +
+ ); +} + +export interface StatePanelProps { + snapshot: AccountSnapshot | null; + orderId: bigint | null; +} + +/** On-chain state read back through simulation. */ +export function StatePanel({ snapshot, orderId }: StatePanelProps) { + return ( +
+

On-chain state

+ + {orderId !== null && ( +

+ Order created this session: #{orderId.toString()} +

+ )} + + {snapshot === null ? ( +

+ Press “Refresh state” to read the contract. +

+ ) : ( +
+
KYC
+
{snapshot.kycVerified ? 'Verified' : 'Not verified'}
+
XLM balance
+
{snapshot.balance.toString()}
+
Trades
+
{snapshot.tradeCount}
+
Volume
+
{snapshot.totalVolume.toString()}
+
Open orders
+
{snapshot.orders.length}
+
+ )} + + {snapshot !== null && snapshot.orders.length > 0 && ( +
    + {snapshot.orders.map((order) => ( +
  • + #{order.orderId.toString()} {order.orderType} {order.tokenIn}→{order.tokenOut}{' '} + {order.amountIn.toString()} — {order.status} +
  • + ))} +
+ )} +
+ ); +} diff --git a/examples/swap-demo/src/config.ts b/examples/swap-demo/src/config.ts new file mode 100644 index 0000000..4f39090 --- /dev/null +++ b/examples/swap-demo/src/config.ts @@ -0,0 +1,91 @@ +/** + * Environment -> SDK configuration. + * + * Everything the demo needs to reach a network comes from `import.meta.env`, so + * no endpoint or contract ID is baked into the source. This module is the only + * place that reads the environment; components receive a finished client. + * + * No variable read here is a secret. Signing keys never pass through the + * environment, because Vite inlines `VITE_`-prefixed values into the browser + * bundle — see `signer.ts`. + */ +import { + NETWORKS, + SwapTradeClient, + type SwapTradeConfig, +} from '@swaptrade/sdk'; +import { resolveSigner, type SignerKind } from './signer.js'; + +export type { SignerKind } from './signer.js'; + +/** Missing configuration, described in terms of what the operator must do. */ +export interface ConfigProblem { + variable: string; + detail: string; +} + +/** Either a usable client or the list of things preventing one. */ +export type ClientSetup = + | { ok: true; client: SwapTradeClient; signerKind: SignerKind } + | { ok: false; problems: ConfigProblem[] }; + +/** Read a variable, treating blank strings as absent. */ +function envVar(name: string): string | undefined { + const raw = (import.meta.env as Record)[name]; + const trimmed = raw?.trim(); + return trimmed === '' ? undefined : trimmed; +} + +/** + * Build the client from the environment. + * + * Returns problems rather than throwing so the UI can render a setup checklist + * instead of a blank screen — a missing contract ID is a configuration mistake, + * not a crash. + */ +export function createClientFromEnv(): ClientSetup { + const problems: ConfigProblem[] = []; + + const contractId = envVar('VITE_CONTRACT_ID'); + if (!contractId) { + problems.push({ + variable: 'VITE_CONTRACT_ID', + detail: 'Contract ID printed by `npm run localnet:deploy`.', + }); + } + + const publicKey = envVar('VITE_PUBLIC_KEY'); + if (!publicKey) { + problems.push({ + variable: 'VITE_PUBLIC_KEY', + detail: 'Public key (G...) to use as the source account.', + }); + } + + if (!contractId || !publicKey) return { ok: false, problems }; + + // Only the localnet endpoint is defaulted; a public network must be explicit. + const config: SwapTradeConfig = { + rpcUrl: envVar('VITE_RPC_URL') ?? NETWORKS.local.rpcUrl, + networkPassphrase: envVar('VITE_NETWORK_PASSPHRASE') ?? NETWORKS.local.networkPassphrase, + contractId, + publicKey, + }; + + const { signer, kind } = resolveSigner(); + if (signer) config.signTransaction = signer; + + try { + return { ok: true, client: new SwapTradeClient(config), signerKind: kind }; + } catch (error) { + return { + ok: false, + problems: [ + { + variable: 'configuration', + detail: error instanceof Error ? error.message : String(error), + }, + ], + }; + } +} diff --git a/examples/swap-demo/src/main.tsx b/examples/swap-demo/src/main.tsx new file mode 100644 index 0000000..b5eaa2a --- /dev/null +++ b/examples/swap-demo/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; +import './styles.css'; + +const container = document.getElementById('root'); +if (!container) throw new Error('Missing #root element in index.html'); + +createRoot(container).render( + + + , +); diff --git a/examples/swap-demo/src/signer.ts b/examples/swap-demo/src/signer.ts new file mode 100644 index 0000000..21e3746 --- /dev/null +++ b/examples/swap-demo/src/signer.ts @@ -0,0 +1,71 @@ +/** + * The demo's signing abstraction. + * + * This module is the only place the demo decides *how* a transaction gets + * signed, and it deliberately offers exactly one browser mechanism: an injected + * Stellar wallet, adapted to the SDK's {@link SignTransaction} callback. + * + * ## Why there is no secret-key path here + * + * Vite inlines every `VITE_`-prefixed variable into the JavaScript it ships to + * the browser. A signing key supplied that way is therefore not "a secret in an + * environment variable" — it is a secret pasted into a public asset, recoverable + * by anyone who opens devtools or fetches the bundle. That is true on localnet + * too, and an example that demonstrates the pattern teaches it. + * + * So the demo cannot sign from a secret key even if someone sets one: no code + * path reads a key, and `keypairSigner` is not imported. Signing authority stays + * with the wallet, which holds the key outside the page and asks the user before + * every signature. + * + * (This file deliberately does not spell out a `VITE_…SECRET…` variable name. + * CI greps the demo's source and bundle for that shape, and a mention in a + * comment would trip it — the sourcemap ships the comment too.) + * + * Node scripts are a different setting — the process is not a public asset — so + * `scripts/verify_localnet.ts` uses `keypairSigner` with an ephemeral key. See + * `docs/LOCALNET.md`. + */ +import { browserWalletSigner, type BrowserWallet, type SignTransaction } from '@swaptrade/sdk'; + +/** Which signing mechanism the demo resolved, so the UI can report it. */ +export type SignerKind = 'browser-wallet' | 'none'; + +/** + * Global shape a Freighter-style extension injects. + * + * Typed as a partial so a half-initialised injection is treated as absent + * rather than trusted. + */ +interface WalletGlobals { + freighterApi?: Partial; +} + +/** + * Find an injected wallet that can actually sign. + * + * Presence of the global is not enough: extensions inject incrementally, and a + * stub without `signTransaction` would fail at the worst moment — after the user + * has already filled in an amount and clicked. + */ +export function detectBrowserWallet(scope: unknown = globalThis): BrowserWallet | undefined { + const candidate = (scope as WalletGlobals | null)?.freighterApi; + return typeof candidate?.signTransaction === 'function' + ? (candidate as BrowserWallet) + : undefined; +} + +/** + * Resolve the signer for this page load. + * + * Returns `kind: 'none'` rather than throwing when no wallet is present: the + * read-only half of the demo (balances, orders, prices) needs no signer, and + * losing it would make a missing extension look like a broken app. + */ +export function resolveSigner(scope: unknown = globalThis): { + signer?: SignTransaction; + kind: SignerKind; +} { + const wallet = detectBrowserWallet(scope); + return wallet ? { signer: browserWalletSigner(wallet), kind: 'browser-wallet' } : { kind: 'none' }; +} diff --git a/examples/swap-demo/src/styles.css b/examples/swap-demo/src/styles.css new file mode 100644 index 0000000..9b18c8f --- /dev/null +++ b/examples/swap-demo/src/styles.css @@ -0,0 +1,169 @@ +/* + * Minimal styling. Issue #254 asks for a demonstration of the workflow, not a + * design system, so this stays small and dependency-free. + */ +:root { + --bg: #0f1115; + --panel: #171a21; + --border: #262b36; + --text: #e6e8ee; + --muted: #98a0b3; + --accent: #3d8bfd; + --warn: #f0a020; + --error: #ff6b6b; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +.app { + max-width: 46rem; + margin: 0 auto; + padding: 2rem 1rem 4rem; + display: grid; + gap: 1rem; +} + +h1 { + margin: 0 0 0.25rem; + font-size: 1.5rem; +} + +h2 { + margin: 0 0 0.75rem; + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem; +} + +.panel--warning { + border-color: var(--warn); +} + +dl { + display: grid; + grid-template-columns: 9rem 1fr; + gap: 0.35rem 1rem; + margin: 0; +} + +dt { + color: var(--muted); +} + +dd { + margin: 0; + overflow-wrap: anywhere; +} + +.field { + display: grid; + gap: 0.25rem; + margin-bottom: 0.75rem; +} + +label { + color: var(--muted); + font-size: 0.85rem; +} + +input { + background: #0d0f14; + border: 1px solid var(--border); + border-radius: 6px; + color: inherit; + padding: 0.5rem 0.6rem; + font: inherit; +} + +button { + background: var(--accent); + border: 0; + border-radius: 6px; + color: #fff; + cursor: pointer; + font: inherit; + padding: 0.5rem 0.9rem; +} + +button:disabled { + background: #2b3140; + color: var(--muted); + cursor: not-allowed; +} + +.steps { + display: grid; + gap: 0.5rem; + list-style: none; + margin: 0 0 1rem; + padding: 0; +} + +ul { + margin: 0; + padding-left: 1.1rem; +} + +li { + overflow-wrap: anywhere; +} + +code { + background: #0d0f14; + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; + padding: 0.1rem 0.3rem; +} + +.badge { + background: #1e3a5f; + border-radius: 4px; + font-size: 0.75rem; + margin: 0 0.4rem; + padding: 0.1rem 0.4rem; +} + +.muted { + color: var(--muted); +} + +/* A status line, not a boxed alert: colour carries the warning, so it stays + * visually lighter than `.alert` below. */ +.notice { + color: var(--warn); + margin: 0 0 1rem; +} + +.alert { + border: 1px solid var(--error); + border-radius: 6px; + margin: 0 0 1rem; + padding: 0.7rem; +} + +.alert strong { + text-transform: capitalize; +} + +.alert p { + margin: 0.35rem 0 0; +} diff --git a/examples/swap-demo/src/useSwapWorkflow.ts b/examples/swap-demo/src/useSwapWorkflow.ts new file mode 100644 index 0000000..76cae8d --- /dev/null +++ b/examples/swap-demo/src/useSwapWorkflow.ts @@ -0,0 +1,143 @@ +/** + * Workflow state for the demo. + * + * All mutation lives here; components read this hook's return value and call its + * actions. Keeping the state machine in one place is what lets the components + * stay free of chain logic. + */ +import { useCallback, useMemo, useState } from 'react'; +import type { SwapTradeClient } from '@swaptrade/sdk'; +import { + type AccountSnapshot, + type StepFailure, + type StepOutcome, + type WorkflowStep, + acceptOrders, + createOrder, + fundAccount, + prepareAccount, + readSnapshot, + toStepFailure, +} from './workflow.js'; + +/** Default demo amounts. Small values keep localnet ledgers readable. */ +export const DEFAULT_AMOUNT = 1_000n; +export const DEFAULT_LIMIT_PRICE = 1_000_000n; +export const DEFAULT_ORACLE_PRICE = 1_000_000n; + +export interface WorkflowState { + /** Step currently running, or `null` when idle. */ + activeStep: WorkflowStep | null; + /** Completed step outcomes, oldest first. */ + outcomes: StepOutcome[]; + /** Most recent failure, cleared when a new step starts. */ + failure: StepFailure | null; + /** ID of the order created in this session, when known. */ + orderId: bigint | null; + /** Latest on-chain snapshot, or `null` before the first refresh. */ + snapshot: AccountSnapshot | null; + /** True while any step or refresh is in flight. */ + busy: boolean; +} + +export interface WorkflowActions { + prepare(): Promise; + create(amountIn: bigint, limitPrice: bigint): Promise; + fund(amount: bigint): Promise; + accept(): Promise; + refresh(): Promise; + reset(): void; +} + +const INITIAL: WorkflowState = { + activeStep: null, + outcomes: [], + failure: null, + orderId: null, + snapshot: null, + busy: false, +}; + +/** + * Drive the create -> fund -> accept workflow. + * + * @param client - A configured client, or `null` when configuration is invalid. + */ +export function useSwapWorkflow(client: SwapTradeClient | null): WorkflowState & WorkflowActions { + const [state, setState] = useState(INITIAL); + + /** + * Run one step with uniform bookkeeping. + * + * Marking the step active before awaiting and clearing it in `finally` means a + * thrown error can never leave the UI stuck on a spinner. + */ + const run = useCallback( + async (step: WorkflowStep, action: (client: SwapTradeClient) => Promise) => { + if (!client) return; + + setState((prev) => ({ ...prev, activeStep: step, failure: null, busy: true })); + try { + await action(client); + } catch (error) { + setState((prev) => ({ ...prev, failure: toStepFailure(step, error) })); + } finally { + setState((prev) => ({ ...prev, activeStep: null, busy: false })); + } + }, + [client], + ); + + const record = useCallback((outcome: StepOutcome, orderId?: bigint) => { + setState((prev) => ({ + ...prev, + outcomes: [...prev.outcomes, outcome], + ...(orderId !== undefined ? { orderId } : {}), + })); + }, []); + + const actions = useMemo( + () => ({ + prepare: () => + run('prepare', async (c) => { + record(await prepareAccount(c, DEFAULT_ORACLE_PRICE)); + }), + + create: (amountIn, limitPrice) => + run('create', async (c) => { + const result = await createOrder(c, { amountIn, limitPrice }); + record(result.outcome, result.orderId); + }), + + fund: (amount) => + run('fund', async (c) => { + record(await fundAccount(c, amount)); + }), + + accept: () => + run('accept', async (c) => { + const result = await acceptOrders(c); + record(result.outcome); + }), + + // Refresh is read-only, so it reports failures without claiming a step. + refresh: async () => { + if (!client) return; + setState((prev) => ({ ...prev, busy: true })); + try { + const snapshot = await readSnapshot(client); + setState((prev) => ({ ...prev, snapshot, failure: null })); + } catch (error) { + setState((prev) => ({ ...prev, failure: toStepFailure('prepare', error) })); + } finally { + setState((prev) => ({ ...prev, busy: false })); + } + }, + + reset: () => setState(INITIAL), + }), + [client, record, run], + ); + + return { ...state, ...actions }; +} diff --git a/examples/swap-demo/src/workflow.ts b/examples/swap-demo/src/workflow.ts new file mode 100644 index 0000000..fd5bdc5 --- /dev/null +++ b/examples/swap-demo/src/workflow.ts @@ -0,0 +1,225 @@ +/** + * The demo workflow, expressed as SDK calls. + * + * This module is the whole boundary between the UI and the chain: components + * import from here and never touch `@swaptrade/sdk`'s transaction plumbing or + * `@stellar/stellar-sdk` at all. + * + * ## Why these contract methods + * + * `swaptrade-contracts/counter` has no `create_swap` / `fund_swap` / + * `accept_swap` trio. The create -> fund -> accept shape from issue #254 is + * therefore mapped onto the primitives the contract actually exposes: + * + * | Demo step | Contract method | + * |-----------|-----------------------| + * | Prepare | `kyc_submit`, `kyc_update_status`, `set_price` | + * | Create | `place_limit_order` | + * | Fund | `mint` | + * | Accept | `execute_due_orders` | + * + * Trading entry points are gated by `require_authenticated_verified_user`, so + * the prepare step is a precondition rather than decoration. + */ +import { + SwapTradeError, + type Order, + type SwapTradeClient, + type TransactionResult, +} from '@swaptrade/sdk'; + +/** Steps the UI can run, in the order they must happen. */ +export const WORKFLOW_STEPS = ['prepare', 'create', 'fund', 'accept'] as const; + +export type WorkflowStep = (typeof WORKFLOW_STEPS)[number]; + +/** Outcome of one step, in terms the UI can render directly. */ +export interface StepOutcome { + step: WorkflowStep; + /** Human-readable summary of what happened on success. */ + summary: string; + /** Transaction hash, when the step submitted one. */ + hash?: string; + /** Final RPC status, when the step submitted a transaction. */ + status?: string; + /** Ledger the transaction landed in, when reported. */ + ledger?: number; +} + +/** A failure, already translated out of SDK internals. */ +export interface StepFailure { + step: WorkflowStep; + message: string; + /** SDK error discriminator, e.g. `CONTRACT_ERROR`. */ + code?: string; + /** Contract error name from `errors.rs`, when the chain rejected the call. */ + contractName?: string; + /** Numeric contract error code, when present. */ + contractCode?: number; +} + +/** Token pair the demo trades. Both are simulated assets minted by the contract. */ +export const TOKEN_IN = 'XLM'; +export const TOKEN_OUT = 'USDCSIM'; + +/** Parameters for the create step. */ +export interface CreateOrderInput { + amountIn: bigint; + limitPrice: bigint; +} + +/** + * Convert any thrown value into a renderable failure. + * + * Contract errors carry the name from `errors.rs`, which is far more actionable + * than the raw `Error(Contract, #500)` the host produces. + */ +export function toStepFailure(step: WorkflowStep, error: unknown): StepFailure { + if (error instanceof SwapTradeError) { + const withContract = error as SwapTradeError & { + contractName?: string; + contractCode?: number; + }; + return { + step, + message: error.message, + code: error.code, + ...(withContract.contractName ? { contractName: withContract.contractName } : {}), + ...(withContract.contractCode !== undefined + ? { contractCode: withContract.contractCode } + : {}), + }; + } + return { step, message: error instanceof Error ? error.message : String(error) }; +} + +/** Shape a `TransactionResult` into a `StepOutcome`. */ +function outcome( + step: WorkflowStep, + summary: string, + result: TransactionResult, +): StepOutcome { + return { + step, + summary, + hash: result.hash, + status: result.status, + ...(result.ledger !== undefined ? { ledger: result.ledger } : {}), + }; +} + +/** + * PREPARE: satisfy the contract's preconditions. + * + * Verifies KYC for the demo account and seeds the oracle price for the pair. + * Both are idempotent enough to re-run: an already-verified account short- + * circuits, and `set_price` overwrites. + */ +export async function prepareAccount( + client: SwapTradeClient, + price: bigint, +): Promise { + const account = client.config.publicKey; + + const alreadyVerified = await client.kycIsVerified(account); + if (!alreadyVerified) { + await client.kycSubmit(account); + // The demo account is its own KYC operator on localnet, where it is also + // the contract admin. On a shared network an operator would do this. + await client.kycUpdateStatus(account, account, 'Verified'); + } + + const result = await client.setPrice(TOKEN_IN, TOKEN_OUT, price); + return outcome( + 'prepare', + alreadyVerified + ? `Account already KYC-verified; oracle price set to ${price}.` + : `Account KYC-verified and oracle price set to ${price}.`, + result, + ); +} + +/** CREATE: place a limit order and return its ID alongside the outcome. */ +export async function createOrder( + client: SwapTradeClient, + input: CreateOrderInput, +): Promise<{ outcome: StepOutcome; orderId?: bigint }> { + const result = await client.placeLimitOrder({ + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, + amountIn: input.amountIn, + limitPrice: input.limitPrice, + }); + + const orderId = result.returnValue; + return { + outcome: outcome( + 'create', + orderId === undefined + ? `Limit order placed for ${input.amountIn} ${TOKEN_IN}.` + : `Limit order #${orderId} placed for ${input.amountIn} ${TOKEN_IN}.`, + result, + ), + ...(orderId !== undefined ? { orderId } : {}), + }; +} + +/** FUND: mint the input token to the account so the order can settle. */ +export async function fundAccount( + client: SwapTradeClient, + amount: bigint, +): Promise { + const result = await client.mint(TOKEN_IN, client.config.publicKey, amount); + return outcome('fund', `Minted ${amount} ${TOKEN_IN} to the demo account.`, result); +} + +/** ACCEPT: execute every order whose conditions are met. */ +export async function acceptOrders( + client: SwapTradeClient, +): Promise<{ outcome: StepOutcome; executedIds: bigint[] }> { + const result = await client.executeDueOrders(); + const executedIds = result.returnValue ?? []; + + return { + outcome: outcome( + 'accept', + executedIds.length === 0 + ? 'No orders were due for execution.' + : `Executed order(s): ${executedIds.map((id) => `#${id}`).join(', ')}.`, + result, + ), + executedIds, + }; +} + +/** Read-only snapshot of on-chain state, for the status panel. */ +export interface AccountSnapshot { + balance: bigint; + tradeCount: number; + totalVolume: bigint; + kycVerified: boolean; + orders: Order[]; +} + +/** + * Read current state without submitting anything. + * + * Every call here is simulate-only, so refreshing costs no fee and needs no + * signer. + */ +export async function readSnapshot(client: SwapTradeClient): Promise { + const [balance, portfolio, kycVerified, orders] = await Promise.all([ + client.balanceOf(TOKEN_IN), + client.getPortfolio(), + client.kycIsVerified(), + client.getUserOrders(), + ]); + + return { + balance, + tradeCount: portfolio.tradeCount, + totalVolume: portfolio.totalVolume, + kycVerified, + orders, + }; +} diff --git a/examples/swap-demo/test/App.test.tsx b/examples/swap-demo/test/App.test.tsx new file mode 100644 index 0000000..5d6cb61 --- /dev/null +++ b/examples/swap-demo/test/App.test.tsx @@ -0,0 +1,259 @@ +/** + * Demo behaviour, from the user's point of view. + * + * Assertions go through rendered text and roles rather than component internals: + * no test reaches into state, props, or the hook. The client is faked at the SDK + * boundary, so the workflow mapping (create -> place_limit_order, fund -> mint, + * accept -> execute_due_orders) is genuinely exercised. + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ContractCallError, SigningError } from '@swaptrade/sdk'; +import { describe, expect, it } from 'vitest'; +import { App } from '../src/App.js'; +import { + DEMO_ACCOUNT, + createFakeClient, + fakeOrder, + fakeSetup, +} from './fakeClient.js'; + +/** Render with a fake client and return it for call assertions. */ +function renderApp(options: Parameters[0] = {}) { + const client = createFakeClient(options); + render(); + return client; +} + +describe('configuration', () => { + it('shows a setup checklist instead of the workflow when config is missing', () => { + render( + , + ); + + expect(screen.getByText('Configuration required')).toBeInTheDocument(); + expect(screen.getByText('VITE_CONTRACT_ID')).toBeInTheDocument(); + // The workflow must not be offered against a contract that isn't configured. + expect(screen.queryByRole('button', { name: /Create order/ })).not.toBeInTheDocument(); + }); + + it('shows the connected account and network', () => { + renderApp(); + expect(screen.getByTestId('account')).toHaveTextContent(DEMO_ACCOUNT); + expect(screen.getByTestId('network')).toHaveTextContent('Standalone Network'); + }); + + it('disables signing actions but keeps refresh available without a signer', () => { + const client = createFakeClient(); + render(); + + expect(screen.getByTestId('no-signer-notice')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create order/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Refresh state' })).toBeEnabled(); + }); + + it('names the browser wallet as the signer and never offers a key input', () => { + renderApp(); + + expect(screen.getByTestId('signer')).toHaveTextContent('Injected browser wallet'); + + // The security property, asserted at the surface a user touches: there is no + // field that could accept a secret key. A regression that added one would + // fail here before it reached a bundle. + for (const field of screen.getAllByRole('textbox')) { + expect(field).toHaveAttribute('id'); + expect(['amount-in', 'limit-price']).toContain(field.getAttribute('id')); + } + expect(document.querySelector('input[type="password"]')).toBeNull(); + expect(screen.queryByLabelText(/secret|private key|seed/i)).not.toBeInTheDocument(); + }); + + it('directs a user without a wallet to install one rather than paste a key', () => { + render(); + + const notice = screen.getByTestId('no-signer-notice'); + expect(notice).toHaveTextContent(/wallet/i); + // The remedy offered must never be "supply a key" — in a variable or a field. + expect(notice).not.toHaveTextContent(/VITE_[A-Z0-9_]*(SECRET|PRIVATE|SEED)/); + expect(notice).not.toHaveTextContent(/paste|enter.*key/i); + }); +}); + +describe('create -> fund -> accept', () => { + it('PREPARE verifies KYC and seeds the oracle price', async () => { + const client = renderApp({ kycVerified: false }); + + await userEvent.click(screen.getByRole('button', { name: /Prepare/ })); + + await waitFor(() => expect(screen.getByTestId('activity-list')).toBeInTheDocument()); + expect(client.kycSubmit).toHaveBeenCalledWith(DEMO_ACCOUNT); + expect(client.kycUpdateStatus).toHaveBeenCalledWith(DEMO_ACCOUNT, DEMO_ACCOUNT, 'Verified'); + expect(client.setPrice).toHaveBeenCalledWith('XLM', 'USDCSIM', 1_000_000n); + expect(screen.getByTestId('status-prepare')).toHaveTextContent('SUCCESS'); + }); + + it('PREPARE skips re-verification for an already-verified account', async () => { + const client = renderApp({ kycVerified: true }); + + await userEvent.click(screen.getByRole('button', { name: /Prepare/ })); + + await waitFor(() => expect(client.setPrice).toHaveBeenCalled()); + expect(client.kycSubmit).not.toHaveBeenCalled(); + expect(screen.getByText(/already KYC-verified/)).toBeInTheDocument(); + }); + + it('CREATE places a limit order with the entered amounts and shows the order ID', async () => { + const client = renderApp({ placedOrderId: 42n }); + + await userEvent.clear(screen.getByLabelText('Amount in (XLM)')); + await userEvent.type(screen.getByLabelText('Amount in (XLM)'), '2500'); + await userEvent.click(screen.getByRole('button', { name: /Create order/ })); + + await waitFor(() => expect(screen.getByTestId('order-id')).toBeInTheDocument()); + expect(client.placeLimitOrder).toHaveBeenCalledWith({ + tokenIn: 'XLM', + tokenOut: 'USDCSIM', + amountIn: 2_500n, + limitPrice: 1_000_000n, + }); + expect(screen.getByTestId('order-id')).toHaveTextContent('#42'); + }); + + it('FUND mints the entered amount to the connected account', async () => { + const client = renderApp(); + + await userEvent.click(screen.getByRole('button', { name: /Fund account/ })); + + await waitFor(() => expect(client.mint).toHaveBeenCalled()); + expect(client.mint).toHaveBeenCalledWith('XLM', DEMO_ACCOUNT, 1_000n); + }); + + it('ACCEPT executes due orders and lists the executed IDs', async () => { + const client = renderApp({ executedIds: [7n, 8n] }); + + await userEvent.click(screen.getByRole('button', { name: /Accept/ })); + + await waitFor(() => expect(client.executeDueOrders).toHaveBeenCalled()); + expect(screen.getByText(/Executed order\(s\): #7, #8/)).toBeInTheDocument(); + }); + + it('reports when nothing was due rather than implying success', async () => { + renderApp({ executedIds: [] }); + + await userEvent.click(screen.getByRole('button', { name: /Accept/ })); + + await waitFor(() => + expect(screen.getByText(/No orders were due for execution\./)).toBeInTheDocument(), + ); + }); + + it('accumulates each step in the activity log with its transaction hash', async () => { + renderApp({ kycVerified: true }); + + await userEvent.click(screen.getByRole('button', { name: /Prepare/ })); + await waitFor(() => expect(screen.getByTestId('hash-prepare')).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: /Create order/ })); + await waitFor(() => expect(screen.getByTestId('hash-create')).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: /Fund account/ })); + await waitFor(() => expect(screen.getByTestId('hash-fund')).toBeInTheDocument()); + await userEvent.click(screen.getByRole('button', { name: /Accept/ })); + await waitFor(() => expect(screen.getByTestId('hash-accept')).toBeInTheDocument()); + + expect(screen.getAllByRole('listitem').length).toBeGreaterThanOrEqual(4); + // Hashes are abbreviated for display but the full value stays available. + expect(screen.getByTestId('hash-create')).toHaveAttribute('title', 'c'.repeat(64)); + }); +}); + +describe('reading on-chain state', () => { + it('starts with no state and fills in after a refresh', async () => { + renderApp({ balance: 4_200n, tradeCount: 3, totalVolume: 12_000n, kycVerified: true }); + + expect(screen.getByTestId('state-empty')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Refresh state' })); + + await waitFor(() => expect(screen.getByTestId('balance')).toHaveTextContent('4200')); + expect(screen.getByTestId('kyc')).toHaveTextContent('Verified'); + expect(screen.getByTestId('trade-count')).toHaveTextContent('3'); + expect(screen.getByTestId('total-volume')).toHaveTextContent('12000'); + }); + + it('lists open orders returned by the contract', async () => { + renderApp({ orders: [fakeOrder({ orderId: 11n, status: 'PartiallyFilled' })] }); + + await userEvent.click(screen.getByRole('button', { name: 'Refresh state' })); + + await waitFor(() => expect(screen.getByTestId('order-list')).toBeInTheDocument()); + expect(screen.getByText(/#11 Limit XLM→USDCSIM 1000 — PartiallyFilled/)).toBeInTheDocument(); + }); +}); + +describe('failure reporting', () => { + it('names the contract error rather than showing a raw host error', async () => { + const client = createFakeClient(); + client.placeLimitOrder.mockRejectedValueOnce( + new ContractCallError('HostError: Error(Contract, #500)', 500, 'KYCVerificationRequired'), + ); + render(); + + await userEvent.click(screen.getByRole('button', { name: /Create order/ })); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + expect(screen.getByTestId('failure-contract')).toHaveTextContent('KYCVerificationRequired'); + expect(screen.getByTestId('failure-contract')).toHaveTextContent('#500'); + }); + + it('reports a rejected signature and re-enables the buttons', async () => { + const client = createFakeClient(); + client.mint.mockRejectedValueOnce(new SigningError('User declined the request')); + render(); + + await userEvent.click(screen.getByRole('button', { name: /Fund account/ })); + + await waitFor(() => + expect(screen.getByTestId('failure-message')).toHaveTextContent('User declined'), + ); + // A failure must not leave the UI stuck mid-step. + expect(screen.getByRole('button', { name: /Fund account/ })).toBeEnabled(); + }); + + it('clears a previous failure when the next step starts', async () => { + const client = createFakeClient({ kycVerified: true }); + client.mint.mockRejectedValueOnce(new SigningError('User declined the request')); + render(); + + await userEvent.click(screen.getByRole('button', { name: /Fund account/ })); + await waitFor(() => expect(screen.getByTestId('failure')).toBeInTheDocument()); + + await userEvent.click(screen.getByRole('button', { name: /Accept/ })); + await waitFor(() => expect(screen.queryByTestId('failure')).not.toBeInTheDocument()); + }); + + it('rejects a non-numeric amount before calling the contract', async () => { + const client = renderApp(); + + await userEvent.clear(screen.getByLabelText('Amount in (XLM)')); + await userEvent.type(screen.getByLabelText('Amount in (XLM)'), '12.5'); + await userEvent.click(screen.getByRole('button', { name: /Create order/ })); + + expect(screen.getByTestId('input-error')).toBeInTheDocument(); + expect(client.placeLimitOrder).not.toHaveBeenCalled(); + }); + + it('rejects a zero amount', async () => { + const client = renderApp(); + + await userEvent.clear(screen.getByLabelText('Amount in (XLM)')); + await userEvent.type(screen.getByLabelText('Amount in (XLM)'), '0'); + await userEvent.click(screen.getByRole('button', { name: /Fund account/ })); + + expect(screen.getByTestId('input-error')).toBeInTheDocument(); + expect(client.mint).not.toHaveBeenCalled(); + }); +}); diff --git a/examples/swap-demo/test/fakeClient.ts b/examples/swap-demo/test/fakeClient.ts new file mode 100644 index 0000000..0f4004d --- /dev/null +++ b/examples/swap-demo/test/fakeClient.ts @@ -0,0 +1,126 @@ +/** + * Test double for the SDK client. + * + * The demo's seam is the client object itself: `src/workflow.ts` only ever calls + * its methods, so replacing it here exercises the real workflow and hook logic + * without a network, a signer, or the Stellar SDK. Tests assert on what the user + * sees, not on how the component is wired. + */ +import type { Order, SwapTradeClient } from '@swaptrade/sdk'; +import { vi } from 'vitest'; +import type { ClientSetup } from '../src/config.js'; +import type { SignerKind } from '../src/signer.js'; + +export const DEMO_ACCOUNT = 'GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57'; +export const DEMO_CONTRACT = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; +export const DEMO_PASSPHRASE = 'Standalone Network ; February 2017'; +export const DEMO_RPC = 'http://localhost:8000/soroban/rpc'; + +/** A representative order, matching the field names the SDK decodes to. */ +export function fakeOrder(overrides: Partial = {}): Order { + return { + orderId: 7n, + owner: DEMO_ACCOUNT, + orderType: 'Limit', + tokenIn: 'XLM', + tokenOut: 'USDCSIM', + amountIn: 1_000n, + amountFilled: 0n, + limitPrice: 1_000_000n, + status: 'Pending', + createdAt: 1_700_000_000n, + ...overrides, + }; +} + +export interface FakeClientOptions { + kycVerified?: boolean; + balance?: bigint; + orders?: Order[]; + tradeCount?: number; + totalVolume?: bigint; + placedOrderId?: bigint; + executedIds?: bigint[]; +} + +export type FakeClient = SwapTradeClient & { + kycIsVerified: ReturnType; + kycSubmit: ReturnType; + kycUpdateStatus: ReturnType; + setPrice: ReturnType; + placeLimitOrder: ReturnType; + mint: ReturnType; + executeDueOrders: ReturnType; + balanceOf: ReturnType; + getPortfolio: ReturnType; + getUserOrders: ReturnType; +}; + +/** Build a fake client whose methods resolve like a healthy contract would. */ +export function createFakeClient(options: FakeClientOptions = {}): FakeClient { + const tx = (hash: string, returnValue?: unknown) => ({ + hash, + status: 'SUCCESS', + ledger: 101, + ...(returnValue !== undefined ? { returnValue } : {}), + }); + + const fake = { + config: { + publicKey: DEMO_ACCOUNT, + contractId: DEMO_CONTRACT, + networkPassphrase: DEMO_PASSPHRASE, + rpcUrl: DEMO_RPC, + allowHttp: true, + fee: '1000000', + timeoutSeconds: 60, + pollTimeoutMs: 30_000, + }, + kycIsVerified: vi.fn(async () => options.kycVerified ?? false), + kycSubmit: vi.fn(async () => tx('k'.repeat(64))), + kycUpdateStatus: vi.fn(async () => tx('u'.repeat(64))), + setPrice: vi.fn(async () => tx('p'.repeat(64))), + placeLimitOrder: vi.fn(async () => tx('c'.repeat(64), options.placedOrderId ?? 7n)), + mint: vi.fn(async () => tx('f'.repeat(64))), + executeDueOrders: vi.fn(async () => tx('a'.repeat(64), options.executedIds ?? [7n])), + balanceOf: vi.fn(async () => options.balance ?? 5_000n), + getPortfolio: vi.fn(async () => ({ + tradeCount: options.tradeCount ?? 2, + totalVolume: options.totalVolume ?? 9_000n, + })), + getUserOrders: vi.fn(async () => options.orders ?? [fakeOrder()]), + }; + + return fake as unknown as FakeClient; +} + +/** Wrap a fake client in the `ClientSetup` shape `App` accepts. */ +export function fakeSetup( + client: SwapTradeClient, + signerKind: SignerKind = 'browser-wallet', +): ClientSetup { + return { ok: true, client, signerKind }; +} + +/** + * A wallet double that signs nothing. + * + * Matches the `BrowserWallet` shape the SDK adapts, so `resolveSigner` accepts + * it. It records what it was asked to sign and returns a placeholder envelope; + * pass `{ reject: true }` to simulate a user declining. No key is involved — + * real signing is covered by the SDK's own signer tests. + */ +export function fakeWallet(options: { reject?: boolean } = {}) { + const requests: { xdr: string; networkPassphrase?: string; address?: string }[] = []; + + return { + requests, + signTransaction: vi.fn( + async (xdr: string, opts: { networkPassphrase?: string; address?: string }) => { + requests.push({ xdr, ...opts }); + if (options.reject) throw new Error('User declined the signature request.'); + return { signedTxXdr: `signed:${xdr}` }; + }, + ), + }; +} diff --git a/examples/swap-demo/test/setup.ts b/examples/swap-demo/test/setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/examples/swap-demo/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/examples/swap-demo/test/signer.test.ts b/examples/swap-demo/test/signer.test.ts new file mode 100644 index 0000000..1bd9e79 --- /dev/null +++ b/examples/swap-demo/test/signer.test.ts @@ -0,0 +1,90 @@ +/** + * Signer resolution. + * + * These tests pin the security property the demo depends on: the only browser + * signing path is an injected wallet, and no environment variable can introduce + * a signing key. A regression here would silently reintroduce a secret into the + * shipped bundle, so it is asserted rather than assumed. + */ +import { describe, expect, it, vi } from 'vitest'; +import { detectBrowserWallet, resolveSigner } from '../src/signer.js'; +import { DEMO_ACCOUNT, DEMO_PASSPHRASE, fakeWallet } from './fakeClient.js'; + +/** + * Stand-in for a key someone might try to supply through the environment. + * + * Deliberately not a valid — or even well-formed — Stellar seed. Its content is + * irrelevant to what the test proves: no code path reads these variables, so + * nothing inspects the value. Using a real seed here would add a + * credential-shaped literal to the repository and prove nothing extra. + */ +const ATTEMPTED_KEY = 'not-a-key-and-never-read'; + +describe('detectBrowserWallet', () => { + it('finds an injected wallet that can sign', () => { + const wallet = fakeWallet(); + expect(detectBrowserWallet({ freighterApi: wallet })).toBe(wallet); + }); + + it('treats a missing global as no wallet', () => { + expect(detectBrowserWallet({})).toBeUndefined(); + expect(detectBrowserWallet(undefined)).toBeUndefined(); + expect(detectBrowserWallet(null)).toBeUndefined(); + }); + + it('rejects a half-injected wallet that cannot sign', () => { + // Extensions inject incrementally. A stub without `signTransaction` must not + // be reported as usable, or the failure would land after the user clicks. + expect(detectBrowserWallet({ freighterApi: {} })).toBeUndefined(); + expect(detectBrowserWallet({ freighterApi: { signTransaction: 'nope' } })).toBeUndefined(); + }); +}); + +describe('resolveSigner', () => { + it('adapts an injected wallet into a signing callback', async () => { + const wallet = fakeWallet(); + const { signer, kind } = resolveSigner({ freighterApi: wallet }); + + expect(kind).toBe('browser-wallet'); + const signed = await signer!('AAAA-envelope', { + networkPassphrase: DEMO_PASSPHRASE, + address: DEMO_ACCOUNT, + }); + + // The adapter forwards network and address so the wallet can warn about a + // wrong chain or a wrong account before the user approves. + expect(wallet.requests).toEqual([ + { xdr: 'AAAA-envelope', networkPassphrase: DEMO_PASSPHRASE, address: DEMO_ACCOUNT }, + ]); + expect(signed).toBe('signed:AAAA-envelope'); + }); + + it('reports no signer when no wallet is present', () => { + const { signer, kind } = resolveSigner({}); + expect(signer).toBeUndefined(); + expect(kind).toBe('none'); + }); + + it('surfaces a declined signature as an error rather than a silent failure', async () => { + const { signer } = resolveSigner({ freighterApi: fakeWallet({ reject: true }) }); + await expect( + signer!('AAAA-envelope', { + networkPassphrase: DEMO_PASSPHRASE, + address: DEMO_ACCOUNT, + }), + ).rejects.toThrow(/declined/i); + }); + + it('never produces a signer from environment variables', () => { + // The guarantee: a key placed in the environment cannot become a signer, + // because no code path reads one. Vite would inline any VITE_ value into the + // public bundle, so this must stay true. + vi.stubEnv('VITE_DEMO_SECRET_KEY', ATTEMPTED_KEY); + vi.stubEnv('VITE_SECRET_KEY', ATTEMPTED_KEY); + + expect(resolveSigner({}).kind).toBe('none'); + expect(resolveSigner({}).signer).toBeUndefined(); + + vi.unstubAllEnvs(); + }); +}); diff --git a/examples/swap-demo/tsconfig.json b/examples/swap-demo/tsconfig.json new file mode 100644 index 0000000..6c53366 --- /dev/null +++ b/examples/swap-demo/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + // `node` is needed by the Playwright/Vite config files, which run outside + // the browser; `vite/client` supplies `import.meta.env`. + "types": ["vite/client", "node"], + + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "test", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"] +} diff --git a/examples/swap-demo/vite.config.ts b/examples/swap-demo/vite.config.ts new file mode 100644 index 0000000..50d99c8 --- /dev/null +++ b/examples/swap-demo/vite.config.ts @@ -0,0 +1,9 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], + server: { port: 5173 }, + preview: { port: 4173 }, + build: { outDir: 'dist', sourcemap: true }, +}); diff --git a/examples/swap-demo/vitest.config.ts b/examples/swap-demo/vitest.config.ts new file mode 100644 index 0000000..19b9330 --- /dev/null +++ b/examples/swap-demo/vitest.config.ts @@ -0,0 +1,13 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./test/setup.ts'], + // `e2e/` is Playwright's; vitest must not try to run it. + include: ['test/**/*.test.tsx', 'test/**/*.test.ts'], + }, +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a697c7c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3089 @@ +{ + "name": "swaptrade-monorepo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swaptrade-monorepo", + "version": "0.1.0", + "license": "Apache-2.0", + "workspaces": [ + "packages/*", + "examples/*" + ], + "engines": { + "node": ">=20" + } + }, + "examples/swap-demo": { + "name": "@swaptrade/swap-demo", + "version": "0.1.0", + "dependencies": { + "@swaptrade/sdk": "0.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@stellar/stellar-sdk": "^14.6.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^22.20.1", + "@types/react": "^19.0.2", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^25.0.1", + "typescript": "^5.6.3", + "vite": "^8.2.1", + "vitest": "^4.1.11" + } + }, + "examples/swap-demo/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "examples/swap-demo/node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "examples/swap-demo/node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stellar/js-xdr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", + "license": "Apache-2.0" + }, + "node_modules/@stellar/stellar-base": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", + "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.9.6", + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.3.1", + "buffer": "^6.0.3", + "sha.js": "^2.4.12" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", + "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^14.1.0", + "axios": "^1.13.3", + "bignumber.js": "^9.3.1", + "commander": "^14.0.2", + "eventsource": "^2.0.2", + "feaxios": "^0.0.23", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + }, + "bin": { + "stellar-js": "bin/stellar-js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@swaptrade/sdk": { + "resolved": "packages/swaptrade-sdk", + "link": true + }, + "node_modules/@swaptrade/swap-demo": { + "resolved": "examples/swap-demo", + "link": true + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "packages/swaptrade-sdk": { + "name": "@swaptrade/sdk", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-sdk": "^14.0.0" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vitest": "^4.1.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..91375c1 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "swaptrade-monorepo", + "version": "0.1.0", + "private": true, + "description": "npm workspace root for the SwapTrade TypeScript SDK and example DApp", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "workspaces": [ + "packages/*", + "examples/*" + ], + "scripts": { + "build": "npm run build --workspace @swaptrade/sdk", + "build:demo": "npm run build --workspace @swaptrade/swap-demo", + "typecheck": "npm run typecheck --workspaces --if-present", + "test": "npm run test --workspaces --if-present", + "test:e2e": "npm run test:e2e --workspace @swaptrade/swap-demo", + "demo": "npm run dev --workspace @swaptrade/swap-demo", + "localnet:deploy": "bash scripts/localnet_deploy.sh", + "localnet:verify": "node --experimental-strip-types scripts/verify_localnet.ts" + } +} diff --git a/packages/swaptrade-sdk/README.md b/packages/swaptrade-sdk/README.md new file mode 100644 index 0000000..5361049 --- /dev/null +++ b/packages/swaptrade-sdk/README.md @@ -0,0 +1,197 @@ +# @swaptrade/sdk + +A lightweight TypeScript wrapper around the SwapTrade Soroban contracts. + +The SDK does one thing: it turns typed JavaScript calls into correctly-encoded +Soroban contract invocations, and turns contract responses back into typed +JavaScript. It does not wrap the parts of `@stellar/stellar-sdk` that are already +clean — keypairs, `StrKey`, XDR primitives — and it holds no state beyond its +configuration. + +```ts +import { SwapTradeClient } from '@swaptrade/sdk'; + +const client = new SwapTradeClient({ + rpcUrl: process.env.SOROBAN_RPC_URL!, + networkPassphrase: process.env.SOROBAN_NETWORK_PASSPHRASE!, + contractId: process.env.SWAPTRADE_CONTRACT_ID!, + publicKey: process.env.SWAPTRADE_PUBLIC_KEY!, + signTransaction: myWalletSigner, +}); + +const balance = await client.balanceOf('XLM'); // simulate only, no fee +const result = await client.mint('XLM', account, 1_000n); // signs and submits +console.log(result.hash, result.status); +``` + +## Install + +The package is not published; it is consumed through the npm workspace at the +repository root. + +```bash +npm install # from the repository root +npm run build --workspace @swaptrade/sdk +``` + +## Configuration + +Every field that determines *which chain and contract you are talking to* is +required. The SDK never falls back to a public network, because a silent default +here means signing a real transaction against the wrong chain. + +| Field | Required | Notes | +| --- | --- | --- | +| `rpcUrl` | yes | Soroban RPC endpoint. | +| `networkPassphrase` | yes | Must match the RPC server's network. | +| `contractId` | yes | `C...`, validated with `StrKey`. | +| `publicKey` | yes | `G...`, used as the source account. | +| `signTransaction` | no | Omit for read-only use. | +| `allowHttp` | no | Defaults to `true` only for loopback hosts. | +| `fee` | no | Stroops per operation. Default `1000000`. | +| `timeoutSeconds` | no | Transaction validity window. Default `60`. | +| `pollTimeoutMs` | no | How long to wait for settlement. Default `30000`. | + +Read these from the environment. Do not hardcode contract IDs, endpoints, keys or +passphrases: + +```ts +import { NETWORKS, networkPreset } from '@swaptrade/sdk'; + +const { rpcUrl, networkPassphrase } = networkPreset('local'); // or 'testnet' +``` + +`NETWORKS` mirrors the values declared in `soroban.toml`. + +### Signers + +`signTransaction` is a callback, so the SDK stays agnostic about wallets: + +```ts +type SignTransaction = ( + xdr: string, + context: { networkPassphrase: string; address: string }, +) => Promise | string; +``` + +Two adapters ship with the SDK: + +- **`browserWalletSigner(wallet)`** — Freighter-style injected wallets. Accepts + both the current `{ signedTxXdr }` response and the older bare-string form. + **This is the only correct choice for a browser app.** +- **`keypairSigner(secret)`** — signs locally from a secret seed. For Node + scripts, CLI tools and tests, where the key stays in the process. + +> **Never use `keypairSigner` in code that ships to a browser.** Bundlers inline +> environment variables into the output — Vite does this for anything +> `VITE_`-prefixed — so a key read from the environment at build time becomes a +> string in a public asset, recoverable from devtools or a plain `fetch`. The +> example DApp in `examples/swap-demo` therefore does not import +> `keypairSigner` at all, and its CI job fails if a secret-shaped variable +> appears in the bundle or is read by its source. + +## What the SDK calls + +The methods map onto `swaptrade-contracts/counter`. Read-only methods simulate +and return a decoded value; state-changing methods simulate, sign, submit and +poll, returning a `TransactionResult`. + +| Category | Methods | +| --- | --- | +| Setup | `initialize`, `getContractVersion` | +| Balances | `mint`, `balanceOf`, `getPortfolio` | +| Orders | `placeLimitOrder`, `getOrder`, `getUserOrders`, `executeDueOrders`, `cancelOrder` | +| Swaps | `swap`, `safeSwap`, `setMaxSlippageBps` | +| Oracle | `setPrice`, `getCurrentPrice` | +| KYC | `kycSubmit`, `kycIsVerified`, `kycGetStatus`, `kycUpdateStatus`, `kycAddOperator` | +| Admin | `pauseTrading`, `resumeTrading`, `getUserTier` | + +For anything not listed, `buildTransaction`, `simulate` and `invoke` are public, +so you can call an arbitrary method without waiting for a wrapper: + +```ts +import { symbolToScVal, u64ToScVal } from '@swaptrade/sdk'; + +await client.invoke('some_new_method', [symbolToScVal('XLM', 'token'), u64ToScVal(1n)]); +``` + +## Amounts are `bigint` + +Contract `i128` / `u128` / `u64` values are `bigint` in and out. A `number` would +silently lose precision above 2^53, so the SDK rejects one rather than truncating: + +```ts +await client.mint('XLM', account, 1000); // throws ValidationError +await client.mint('XLM', account, 1000n); // correct +``` + +## Errors + +Every failure is a `SwapTradeError` subclass with a `code`, so callers branch on +a discriminator instead of matching message strings. + +| Class | `code` | Means | +| --- | --- | --- | +| `ConfigError` | `CONFIG_INVALID` | Missing or malformed configuration. | +| `ValidationError` | `ADDRESS_INVALID`, `CONTRACT_ID_INVALID`, `AMOUNT_INVALID`, `SYMBOL_INVALID` | A bad argument, caught before any network call. | +| `SimulationError` | `SIMULATION_FAILED` | Simulation failed. Nothing was submitted; no fee was charged. | +| `SigningError` | `SIGNING_FAILED` | Signer rejected the request or returned unusable XDR. | +| `RpcError` | `RPC_FAILED` | Transport-level failure reaching the RPC server. | +| `TransactionFailedError` | `TRANSACTION_FAILED` | Rejected by the network, or failed on-chain. | +| `TransactionTimeoutError` | `TRANSACTION_TIMEOUT` | Did not settle within `pollTimeoutMs`. Carries `hash` so you can keep checking. | +| `ContractCallError` | `CONTRACT_ERROR` | The contract returned an error. Carries `contractCode` and `contractName`. | + +`ContractCallError` resolves the numeric code against the catalogue in +`counter/src/errors.rs`, which turns an opaque host error into something +actionable: + +```ts +try { + await client.placeLimitOrder({ /* ... */ }); +} catch (error) { + if (error instanceof ContractCallError) { + // "KYCVerificationRequired (#500)" rather than "Error(Contract, #500)" + console.error(error.contractName, error.contractCode); + } +} +``` + +## Module layout + +| File | Responsibility | +| --- | --- | +| `src/client.ts` | `SwapTradeClient`: build, simulate, sign, submit, poll; one method per contract entry point. | +| `src/config.ts` | Validation and defaults. Produces a frozen `ResolvedConfig`. | +| `src/scval.ts` | ScVal encoding and decoding, including `Option`, unit enums and tuples. | +| `src/errors.ts` | Error classes and the contract error-code catalogue. | +| `src/types.ts` | Public types mirroring the on-chain structs. | +| `src/signers.ts` | Wallet and keypair signer adapters. | + +## Encoding notes + +These are the places where the Rust and JavaScript type systems do not line up, +and where a naive mapping would be wrong: + +- **`Option::None`** encodes to `ScVal::Void` and decodes to `undefined`, not `0`. + Collapsing it would make "no expiry" indistinguishable from "expired at epoch". +- **Fieldless enums** encode as a single-element vector of the variant name, + e.g. `KYCStatus::Verified` → `scvVec([Symbol("Verified")])`. +- **Tuples** encode as vectors: `(Symbol, Symbol)` → `scvVec([sym, sym])`. +- **`symbol_short!`** is capped at 9 characters; general `Symbol` at 32. The SDK + validates against the correct limit per argument. + +## Testing + +```bash +npm run test --workspace @swaptrade/sdk +``` + +95 tests, no network access. The `RpcServerLike` interface is the injection +point: tests supply a fake server, and everything above it — argument encoding, +transaction building, simulation handling, signing, submission and polling — is +the real implementation. Tests decode the built XDR to assert on the exact +method name and argument list that would reach the contract. + +```ts +const client = new SwapTradeClient(config, { server: myFakeServer }); +``` diff --git a/packages/swaptrade-sdk/package.json b/packages/swaptrade-sdk/package.json new file mode 100644 index 0000000..a66a57f --- /dev/null +++ b/packages/swaptrade-sdk/package.json @@ -0,0 +1,32 @@ +{ + "name": "@swaptrade/sdk", + "version": "0.1.0", + "description": "Lightweight TypeScript SDK for the SwapTrade Soroban contracts", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@stellar/stellar-sdk": "^14.0.0" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vitest": "^4.1.11" + } +} diff --git a/packages/swaptrade-sdk/src/client.ts b/packages/swaptrade-sdk/src/client.ts new file mode 100644 index 0000000..4cf8e2b --- /dev/null +++ b/packages/swaptrade-sdk/src/client.ts @@ -0,0 +1,680 @@ +/** + * `SwapTradeClient` — a thin, typed wrapper over the SwapTrade Soroban contract. + * + * The client owns the mechanical parts of a Soroban call (build -> simulate -> + * sign -> submit -> poll) and exposes one method per contract entry point. It + * deliberately does not re-implement anything `@stellar/stellar-sdk` already + * does well; it only removes the boilerplate and adds typing plus a consistent + * error taxonomy. + * + * Method names and argument order mirror `swaptrade-contracts/counter/src/lib.rs`. + */ +import { + Contract, + TransactionBuilder, + rpc as StellarRpc, + type Account, + type Transaction, + type xdr, +} from '@stellar/stellar-sdk'; +import { + assertAccountId, + assertPositiveAmount, + assertSymbol, + resolveConfig, +} from './config.js'; +import { + ConfigError, + ContractCallError, + RpcError, + SigningError, + SimulationError, + SwapTradeError, + TransactionFailedError, + TransactionTimeoutError, +} from './errors.js'; +import { + accountArg, + asContractError, + decodeKycStatus, + decodeOrder, + decodePortfolio, + fromScVal, + i128ToScVal, + kycStatusToScVal, + optionToScVal, + symbolToScVal, + tupleToScVal, + u128ToScVal, + u32ToScVal, + u64ToScVal, +} from './scval.js'; +import type { + KYCStatus, + Order, + PlaceLimitOrderParams, + PortfolioSummary, + ResolvedConfig, + SimulationResult, + SwapTradeConfig, + TransactionResult, +} from './types.js'; + +/** How long to wait between `getTransaction` polls, in milliseconds. */ +const POLL_INTERVAL_MS = 1_000; + +/** Minimal surface of the RPC server the client depends on. */ +export interface RpcServerLike { + getAccount(address: string): Promise; + simulateTransaction(tx: Transaction): Promise; + sendTransaction(tx: Transaction): Promise; + getTransaction(hash: string): Promise; +} + +/** Options for constructing a {@link SwapTradeClient}. */ +export interface ClientOptions { + /** + * Pre-built RPC server, primarily for tests. + * When omitted a `rpc.Server` is created from the resolved config. + */ + server?: RpcServerLike; +} + +function errorMessage(value: unknown): string { + if (value instanceof Error) return value.message; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Classify an unknown thrown value from the RPC layer. + * + * Contract errors are surfaced as {@link ContractCallError} so callers can read + * `contractCode`; anything else is transport failure. + */ +function classifyRpcFailure(cause: unknown, action: string): SwapTradeError { + if (cause instanceof SwapTradeError) return cause; + const message = errorMessage(cause); + return asContractError(message, cause) ?? new RpcError(`${action}: ${message}`, cause); +} + +export class SwapTradeClient { + readonly config: ResolvedConfig; + private readonly contract: Contract; + private readonly server: RpcServerLike; + + /** + * @param config - Connection, contract and signing configuration. + * @param options - Optional overrides, mainly for testing. + * @throws {ConfigError} when required configuration is missing or malformed. + * @throws {ValidationError} when the contract ID or public key is invalid. + */ + constructor(config: SwapTradeConfig, options: ClientOptions = {}) { + this.config = resolveConfig(config); + this.contract = new Contract(this.config.contractId); + this.server = + options.server ?? + (new StellarRpc.Server(this.config.rpcUrl, { + allowHttp: this.config.allowHttp, + }) as unknown as RpcServerLike); + } + + /** Convenience factory; equivalent to `new SwapTradeClient(...)`. */ + static create(config: SwapTradeConfig, options: ClientOptions = {}): SwapTradeClient { + return new SwapTradeClient(config, options); + } + + // ── Transaction plumbing ────────────────────────────────────────────────── + + /** + * Build an unsigned transaction invoking `method` with `args`. + * + * Exposed so callers can inspect or externally sign the envelope instead of + * using {@link invoke}. + */ + async buildTransaction(method: string, args: xdr.ScVal[] = []): Promise { + let account: Awaited>; + try { + account = await this.server.getAccount(this.config.publicKey); + } catch (cause) { + throw classifyRpcFailure( + cause, + `Could not load source account ${this.config.publicKey}. Confirm it exists and is funded on this network`, + ); + } + + return new TransactionBuilder(account as Account, { + fee: this.config.fee, + networkPassphrase: this.config.networkPassphrase, + }) + .addOperation(this.contract.call(method, ...args)) + .setTimeout(this.config.timeoutSeconds) + .build(); + } + + /** + * Simulate a call without submitting it. + * + * Useful for read-only contract methods and for previewing whether a + * state-changing call would succeed. Costs nothing and needs no signature. + * + * @throws {SimulationError} when the host reports a simulation error. + */ + async simulate( + method: string, + args: xdr.ScVal[] = [], + decode?: (raw: unknown) => T, + ): Promise> { + const tx = await this.buildTransaction(method, args); + + let sim: unknown; + try { + sim = await this.server.simulateTransaction(tx); + } catch (cause) { + throw classifyRpcFailure(cause, `Simulation of "${method}" failed`); + } + + if (StellarRpc.Api.isSimulationError(sim as never)) { + const raw = (sim as { error: string }).error; + const events = ((sim as { events?: unknown[] }).events ?? []).map(errorMessage); + throw ( + asContractError(raw) ?? + new SimulationError(`Simulation of "${method}" failed: ${raw}`, events) + ); + } + + const result = (sim as { result?: { retval?: xdr.ScVal } }).result; + const raw = result?.retval ? fromScVal(result.retval) : undefined; + const minResourceFee = (sim as { minResourceFee?: string }).minResourceFee; + + return { + returnValue: (decode ? decode(raw) : (raw as T)), + ...(minResourceFee ? { minResourceFee } : {}), + }; + } + + /** + * Build, simulate, sign, submit and await a state-changing call. + * + * Simulation runs first so authorization and resource footprint are attached + * before signing, and so a call that cannot succeed fails without spending a + * fee. + * + * @throws {ConfigError} when no `signTransaction` was configured. + * @throws {SigningError} when the signer rejects or returns unusable XDR. + * @throws {SimulationError} when the call would fail on-chain. + * @throws {TransactionFailedError} when the network rejects the transaction. + * @throws {TransactionTimeoutError} when it does not settle in time. + */ + async invoke( + method: string, + args: xdr.ScVal[] = [], + decode?: (raw: unknown) => T, + ): Promise> { + const { signTransaction } = this.config; + if (!signTransaction) { + throw new ConfigError( + `Cannot invoke "${method}": no signTransaction callback was configured. Provide one to send transactions, or use simulate() for read-only calls.`, + ); + } + + const tx = await this.buildTransaction(method, args); + + // Simulate and assemble so the transaction carries the correct Soroban + // resource footprint and auth entries before it is signed. + let sim: unknown; + try { + sim = await this.server.simulateTransaction(tx); + } catch (cause) { + throw classifyRpcFailure(cause, `Simulation of "${method}" failed`); + } + + if (StellarRpc.Api.isSimulationError(sim as never)) { + const raw = (sim as { error: string }).error; + throw ( + asContractError(raw) ?? + new SimulationError(`Simulation of "${method}" failed: ${raw}`) + ); + } + + const prepared = StellarRpc.assembleTransaction(tx, sim as never).build(); + + let signedXdr: string; + try { + signedXdr = await signTransaction(prepared.toXDR(), { + networkPassphrase: this.config.networkPassphrase, + address: this.config.publicKey, + }); + } catch (cause) { + throw new SigningError( + `Signing "${method}" was rejected or failed: ${errorMessage(cause)}`, + cause, + ); + } + + if (typeof signedXdr !== 'string' || signedXdr.trim() === '') { + throw new SigningError( + `Signing "${method}" returned no transaction XDR. The signer must return the signed envelope as a base64 string.`, + ); + } + + let signed: Transaction; + try { + signed = TransactionBuilder.fromXDR( + signedXdr, + this.config.networkPassphrase, + ) as Transaction; + } catch (cause) { + throw new SigningError( + `Signer returned XDR that could not be parsed for "${method}": ${errorMessage(cause)}`, + cause, + ); + } + + let sent: { status?: string; hash?: string; errorResult?: unknown }; + try { + sent = (await this.server.sendTransaction(signed)) as typeof sent; + } catch (cause) { + throw classifyRpcFailure(cause, `Submitting "${method}" failed`); + } + + if (sent.status === 'ERROR' || sent.status === 'DUPLICATE') { + throw new TransactionFailedError( + `Network rejected "${method}" with status ${sent.status}: ${errorMessage(sent.errorResult)}`, + sent.hash, + sent.status, + ); + } + + const hash = sent.hash; + if (!hash) { + throw new TransactionFailedError( + `Submitting "${method}" returned no transaction hash.`, + undefined, + sent.status, + ); + } + + return this.awaitTransaction(hash, method, decode); + } + + /** + * Poll `getTransaction` until the transaction settles. + * + * @throws {TransactionTimeoutError} when the poll window elapses first. + */ + private async awaitTransaction( + hash: string, + method: string, + decode?: (raw: unknown) => T, + ): Promise> { + const deadline = Date.now() + this.config.pollTimeoutMs; + + for (;;) { + let result: { + status?: string; + returnValue?: xdr.ScVal; + ledger?: number; + resultXdr?: unknown; + }; + try { + result = (await this.server.getTransaction(hash)) as typeof result; + } catch (cause) { + throw classifyRpcFailure(cause, `Polling transaction ${hash} failed`); + } + + const status = result.status ?? 'NOT_FOUND'; + + if (status === 'SUCCESS') { + const raw = result.returnValue ? fromScVal(result.returnValue) : undefined; + return { + hash, + status, + ...(result.ledger !== undefined ? { ledger: result.ledger } : {}), + ...(raw !== undefined + ? { returnValue: (decode ? decode(raw) : (raw as T)) } + : {}), + }; + } + + if (status === 'FAILED') { + const detail = errorMessage(result.resultXdr); + throw ( + asContractError(detail) ?? + new TransactionFailedError( + `Transaction ${hash} for "${method}" failed on-chain: ${detail}`, + hash, + status, + ) + ); + } + + if (Date.now() >= deadline) { + throw new TransactionTimeoutError(hash, this.config.pollTimeoutMs); + } + + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + } + + // ── Contract lifecycle ──────────────────────────────────────────────────── + + /** `initialize()` — set the stored contract version after deployment. */ + async initialize(): Promise> { + return this.invoke('initialize'); + } + + /** `get_contract_version() -> u32`. */ + async getContractVersion(): Promise { + const { returnValue } = await this.simulate('get_contract_version', [], (raw) => + Number(raw ?? 0), + ); + return returnValue; + } + + // ── Balances ────────────────────────────────────────────────────────────── + + /** + * `mint(token: Symbol, to: Address, amount: i128)`. + * + * The demo contract mints simulated balances, which is how a test account is + * funded before trading. + */ + async mint(token: string, to: string, amount: bigint): Promise> { + return this.invoke('mint', [ + symbolToScVal(token, 'token'), + accountArg(to, 'recipient'), + i128ToScVal(assertPositiveAmount(amount)), + ]); + } + + /** + * `balance_of(token: Symbol, user: Address) -> i128`. + * + * Read-only, so it is simulated rather than submitted. + */ + async balanceOf(token: string, user?: string): Promise { + const address = user ?? this.config.publicKey; + const { returnValue } = await this.simulate( + 'balance_of', + [symbolToScVal(token, 'token'), accountArg(address, 'user')], + (raw) => (typeof raw === 'bigint' ? raw : BigInt(Number(raw ?? 0))), + ); + return returnValue; + } + + /** `get_portfolio(user: Address) -> (u32, i128)`. */ + async getPortfolio(user?: string): Promise { + const address = user ?? this.config.publicKey; + const { returnValue } = await this.simulate( + 'get_portfolio', + [accountArg(address, 'user')], + decodePortfolio, + ); + return returnValue; + } + + // ── Orders: the create -> fund -> accept demo path ──────────────────────── + + /** + * `place_limit_order(token_in, token_out, amount_in, limit_price, expires_at, user) -> u64` + * + * This is the contract's "create an offer" primitive and returns the new + * order ID. + */ + async placeLimitOrder(params: PlaceLimitOrderParams): Promise> { + const user = params.user ?? this.config.publicKey; + return this.invoke( + 'place_limit_order', + [ + symbolToScVal(params.tokenIn, 'tokenIn'), + symbolToScVal(params.tokenOut, 'tokenOut'), + i128ToScVal(assertPositiveAmount(params.amountIn, 'amountIn')), + u128ToScVal(assertPositiveAmount(params.limitPrice, 'limitPrice')), + optionToScVal(params.expiresAt, u64ToScVal), + accountArg(user, 'user'), + ], + (raw) => (typeof raw === 'bigint' ? raw : BigInt(Number(raw ?? 0))), + ); + } + + /** `get_order(order_id: u64) -> Order`. */ + async getOrder(orderId: bigint): Promise { + const { returnValue } = await this.simulate( + 'get_order', + [u64ToScVal(orderId)], + decodeOrder, + ); + return returnValue; + } + + /** `get_user_orders(user: Address) -> Vec`. */ + async getUserOrders(user?: string): Promise { + const address = user ?? this.config.publicKey; + const { returnValue } = await this.simulate( + 'get_user_orders', + [accountArg(address, 'user')], + (raw) => (Array.isArray(raw) ? raw.map(decodeOrder) : []), + ); + return returnValue; + } + + /** + * `execute_due_orders() -> Vec` + * + * Settles every order whose conditions are met and returns the executed IDs — + * the counterparty half of the demo flow. + */ + async executeDueOrders(): Promise> { + return this.invoke('execute_due_orders', [], (raw) => + Array.isArray(raw) ? raw.map((id) => (typeof id === 'bigint' ? id : BigInt(Number(id)))) : [], + ); + } + + /** `cancel_order(order_id: u64, user: Address)`. */ + async cancelOrder(orderId: bigint, user?: string): Promise> { + const address = user ?? this.config.publicKey; + return this.invoke('cancel_order', [ + u64ToScVal(orderId), + accountArg(address, 'user'), + ]); + } + + // ── Direct swaps ────────────────────────────────────────────────────────── + + /** + * `swap(from: Symbol, to: Symbol, amount: i128, user: Address) -> i128` + * + * Reverts on any failure; see {@link safeSwap} for the non-reverting variant. + */ + async swap( + from: string, + to: string, + amount: bigint, + user?: string, + ): Promise> { + const address = user ?? this.config.publicKey; + if (assertSymbol(from, 'from') === assertSymbol(to, 'to')) { + throw new ContractCallError('Cannot swap a token for itself: "from" and "to" must differ.'); + } + return this.invoke( + 'swap', + [ + symbolToScVal(from, 'from'), + symbolToScVal(to, 'to'), + i128ToScVal(assertPositiveAmount(amount)), + accountArg(address, 'user'), + ], + (raw) => (typeof raw === 'bigint' ? raw : BigInt(Number(raw ?? 0))), + ); + } + + /** + * `safe_swap(from, to, amount, user, deadline) -> i128` + * + * Returns `0` instead of reverting when the swap cannot proceed. + */ + async safeSwap( + from: string, + to: string, + amount: bigint, + deadline: bigint, + user?: string, + ): Promise> { + const address = user ?? this.config.publicKey; + return this.invoke( + 'safe_swap', + [ + symbolToScVal(from, 'from'), + symbolToScVal(to, 'to'), + i128ToScVal(assertPositiveAmount(amount)), + accountArg(address, 'user'), + u64ToScVal(deadline), + ], + (raw) => (typeof raw === 'bigint' ? raw : BigInt(Number(raw ?? 0))), + ); + } + + // ── Oracle prices ───────────────────────────────────────────────────────── + + /** + * `set_price(token_pair: (Symbol, Symbol), price: u128)` + * + * Orders and swaps consult the oracle, so a localnet demo must seed a price + * before trading. + */ + async setPrice( + from: string, + to: string, + price: bigint, + ): Promise> { + return this.invoke('set_price', [ + this.tokenPair(from, to), + u128ToScVal(assertPositiveAmount(price, 'price')), + ]); + } + + /** `get_current_price(token_pair: (Symbol, Symbol)) -> u128`. */ + async getCurrentPrice(from: string, to: string): Promise { + const { returnValue } = await this.simulate( + 'get_current_price', + [this.tokenPair(from, to)], + (raw) => (typeof raw === 'bigint' ? raw : BigInt(Number(raw ?? 0))), + ); + return returnValue; + } + + /** Encode a `(Symbol, Symbol)` token-pair tuple argument. */ + private tokenPair(from: string, to: string): xdr.ScVal { + return tupleToScVal([ + symbolToScVal(from, 'from'), + symbolToScVal(to, 'to'), + ]); + } + + // ── KYC ─────────────────────────────────────────────────────────────────── + + /** `kyc_is_verified(user: Address) -> bool`. */ + async kycIsVerified(user?: string): Promise { + const address = user ?? this.config.publicKey; + const { returnValue } = await this.simulate( + 'kyc_is_verified', + [accountArg(address, 'user')], + (raw) => raw === true, + ); + return returnValue; + } + + /** `kyc_submit(user: Address)` — user-initiated KYC submission. */ + async kycSubmit(user?: string): Promise> { + const address = user ?? this.config.publicKey; + return this.invoke('kyc_submit', [accountArg(address, 'user')]); + } + + /** `kyc_add_operator(admin: Address, operator: Address)` — admin only. */ + async kycAddOperator(admin: string, operator: string): Promise> { + return this.invoke('kyc_add_operator', [ + accountArg(admin, 'admin'), + accountArg(operator, 'operator'), + ]); + } + + /** + * `kyc_update_status(operator, user, new_status, reason)` — operator only. + * + * Trading entry points are gated on `Verified`, so the demo must walk an + * account through `Pending -> InReview -> Verified`. + */ + async kycUpdateStatus( + operator: string, + user: string, + newStatus: KYCStatus, + reason?: string, + ): Promise> { + return this.invoke('kyc_update_status', [ + accountArg(operator, 'operator'), + accountArg(user, 'user'), + kycStatusToScVal(newStatus), + optionToScVal(reason, (r) => symbolToScVal(r, 'reason', false)), + ]); + } + + /** `kyc_get_record(user: Address) -> KYCRecord`; returns the status field. */ + async kycGetStatus(user?: string): Promise { + const address = user ?? this.config.publicKey; + const { returnValue } = await this.simulate( + 'kyc_get_record', + [accountArg(address, 'user')], + (raw) => { + const record = (raw ?? {}) as Record; + return decodeKycStatus(record['status']); + }, + ); + return returnValue; + } + + // ── Admin ───────────────────────────────────────────────────────────────── + + /** `pause_trading(caller: Address) -> bool` — admin only. */ + async pauseTrading(caller?: string): Promise> { + const address = caller ?? this.config.publicKey; + return this.invoke( + 'pause_trading', + [accountArg(address, 'caller')], + (raw) => raw === true, + ); + } + + /** `resume_trading(caller: Address) -> bool` — admin only. */ + async resumeTrading(caller?: string): Promise> { + const address = caller ?? this.config.publicKey; + return this.invoke( + 'resume_trading', + [accountArg(address, 'caller')], + (raw) => raw === true, + ); + } + + /** `get_user_tier(user: Address) -> UserTier`. */ + async getUserTier(user?: string): Promise { + const address = user ?? this.config.publicKey; + const { returnValue } = await this.simulate( + 'get_user_tier', + [accountArg(address, 'user')], + (raw) => (Array.isArray(raw) ? String(raw[0]) : String(raw ?? 'Unknown')), + ); + return returnValue; + } + + /** `set_max_slippage_bps(bps: u32)`. */ + async setMaxSlippageBps(bps: number): Promise> { + if (!Number.isInteger(bps) || bps < 0 || bps > 10_000) { + throw new ContractCallError('Slippage must be an integer between 0 and 10000 basis points.'); + } + return this.invoke('set_max_slippage_bps', [u32ToScVal(bps)]); + } +} diff --git a/packages/swaptrade-sdk/src/config.ts b/packages/swaptrade-sdk/src/config.ts new file mode 100644 index 0000000..3a0b921 --- /dev/null +++ b/packages/swaptrade-sdk/src/config.ts @@ -0,0 +1,189 @@ +import { StrKey } from '@stellar/stellar-sdk'; +import { ConfigError, ValidationError } from './errors.js'; +import { NETWORKS, type NetworkName, type ResolvedConfig, type SwapTradeConfig } from './types.js'; + +/** Default fee offered per operation, in stroops (0.1 XLM). */ +export const DEFAULT_FEE = '1000000'; +/** Default transaction validity window, in seconds. */ +export const DEFAULT_TIMEOUT_SECONDS = 60; +/** Default time to wait for a submitted transaction to settle, in milliseconds. */ +export const DEFAULT_POLL_TIMEOUT_MS = 30_000; + +/** Soroban `Symbol` values are limited to 32 characters. */ +const MAX_SYMBOL_LENGTH = 32; +/** `symbol_short!` values — used for asset codes in this contract — allow 9. */ +const MAX_SHORT_SYMBOL_LENGTH = 9; + +/** Hosts for which plain HTTP is considered safe (local development). */ +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); + +/** Assert a value is a valid Stellar public key (`G...`). */ +export function assertAccountId(value: unknown, label = 'public key'): string { + if (typeof value !== 'string' || value.length === 0) { + throw new ValidationError('ADDRESS_INVALID', `Invalid ${label}: expected a non-empty string.`); + } + if (!StrKey.isValidEd25519PublicKey(value)) { + throw new ValidationError( + 'ADDRESS_INVALID', + `Invalid ${label}: "${value}" is not a valid Stellar account ID (expected a G... address).`, + ); + } + return value; +} + +/** Assert a value is a valid Soroban contract ID (`C...`). */ +export function assertContractId(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) { + throw new ValidationError( + 'CONTRACT_ID_INVALID', + 'Invalid contract ID: expected a non-empty string.', + ); + } + if (!StrKey.isValidContract(value)) { + throw new ValidationError( + 'CONTRACT_ID_INVALID', + `Invalid contract ID: "${value}" is not a valid Soroban contract ID (expected a C... address).`, + ); + } + return value; +} + +/** + * Assert a value is usable as a Soroban `Symbol`. + * + * The contract stores asset codes with `symbol_short!`, which caps length at 9; + * pass `short: false` for the general 32-character `Symbol` limit. + */ +export function assertSymbol(value: unknown, label = 'symbol', short = true): string { + if (typeof value !== 'string' || value.length === 0) { + throw new ValidationError('SYMBOL_INVALID', `Invalid ${label}: expected a non-empty string.`); + } + const limit = short ? MAX_SHORT_SYMBOL_LENGTH : MAX_SYMBOL_LENGTH; + if (value.length > limit) { + throw new ValidationError( + 'SYMBOL_INVALID', + `Invalid ${label}: "${value}" is ${value.length} characters but the contract allows at most ${limit}.`, + ); + } + if (!/^[A-Za-z0-9_]+$/.test(value)) { + throw new ValidationError( + 'SYMBOL_INVALID', + `Invalid ${label}: "${value}" must contain only letters, digits and underscores.`, + ); + } + return value; +} + +/** Assert an amount is a strictly positive integer, as the contract requires. */ +export function assertPositiveAmount(value: unknown, label = 'amount'): bigint { + if (typeof value !== 'bigint') { + throw new ValidationError( + 'AMOUNT_INVALID', + `Invalid ${label}: expected a bigint, received ${typeof value}. Use BigInt(...) to avoid precision loss on i128 values.`, + ); + } + if (value <= 0n) { + throw new ValidationError('AMOUNT_INVALID', `Invalid ${label}: must be greater than zero.`); + } + return value; +} + +/** Resolve a {@link NetworkName} to its RPC URL and passphrase. */ +export function networkPreset(name: NetworkName): { rpcUrl: string; networkPassphrase: string } { + const preset = NETWORKS[name]; + if (!preset) { + throw new ConfigError( + `Unknown network "${name}". Expected one of: ${Object.keys(NETWORKS).join(', ')}.`, + ); + } + return { rpcUrl: preset.rpcUrl, networkPassphrase: preset.networkPassphrase }; +} + +function isLoopback(rpcUrl: string): boolean { + try { + return LOOPBACK_HOSTS.has(new URL(rpcUrl).hostname); + } catch { + return false; + } +} + +/** + * Validate a {@link SwapTradeConfig} and apply defaults. + * + * Nothing is silently defaulted that would send traffic to a network the caller + * did not name: `rpcUrl`, `networkPassphrase` and `contractId` are all required. + * + * @throws {ConfigError} when a required field is missing or malformed. + * @throws {ValidationError} when the contract ID or public key is invalid. + */ +export function resolveConfig(config: SwapTradeConfig): ResolvedConfig { + if (config === null || typeof config !== 'object') { + throw new ConfigError('Missing configuration: expected a SwapTradeConfig object.'); + } + + const { rpcUrl, networkPassphrase } = config; + + if (typeof rpcUrl !== 'string' || rpcUrl.trim() === '') { + throw new ConfigError( + 'Missing "rpcUrl". Set it explicitly (e.g. from NETWORKS.local.rpcUrl or a VITE_SOROBAN_RPC_URL env var).', + ); + } + + let parsed: URL; + try { + parsed = new URL(rpcUrl); + } catch (cause) { + throw new ConfigError(`Invalid "rpcUrl": "${rpcUrl}" is not a valid URL.`, cause); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new ConfigError( + `Invalid "rpcUrl": protocol "${parsed.protocol}" is not supported, expected http: or https:.`, + ); + } + + if (typeof networkPassphrase !== 'string' || networkPassphrase.trim() === '') { + throw new ConfigError( + 'Missing "networkPassphrase". A wrong passphrase produces signatures the network rejects, so it is never defaulted.', + ); + } + + const allowHttp = config.allowHttp ?? isLoopback(rpcUrl); + if (parsed.protocol === 'http:' && !allowHttp) { + throw new ConfigError( + `Refusing to use plain HTTP for non-local RPC URL "${rpcUrl}". Use https, or set allowHttp: true to override.`, + ); + } + + const fee = config.fee ?? DEFAULT_FEE; + if (!/^\d+$/.test(fee)) { + throw new ConfigError(`Invalid "fee": "${fee}" must be a whole number of stroops.`); + } + + const timeoutSeconds = config.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) { + throw new ConfigError('Invalid "timeoutSeconds": must be a positive integer.'); + } + + const pollTimeoutMs = config.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; + if (!Number.isInteger(pollTimeoutMs) || pollTimeoutMs <= 0) { + throw new ConfigError('Invalid "pollTimeoutMs": must be a positive integer.'); + } + + if (config.signTransaction !== undefined && typeof config.signTransaction !== 'function') { + throw new ConfigError('Invalid "signTransaction": must be a function when provided.'); + } + + const resolved: ResolvedConfig = { + rpcUrl, + networkPassphrase, + contractId: assertContractId(config.contractId), + publicKey: assertAccountId(config.publicKey), + allowHttp, + fee, + timeoutSeconds, + pollTimeoutMs, + ...(config.signTransaction ? { signTransaction: config.signTransaction } : {}), + }; + + return Object.freeze(resolved); +} diff --git a/packages/swaptrade-sdk/src/errors.ts b/packages/swaptrade-sdk/src/errors.ts new file mode 100644 index 0000000..dfc6715 --- /dev/null +++ b/packages/swaptrade-sdk/src/errors.ts @@ -0,0 +1,190 @@ +/** + * Error taxonomy for the SwapTrade SDK. + * + * Every failure the SDK can produce is one of these classes, so callers can + * branch on `err.code` instead of matching on message strings. The contract + * error codes mirror `swaptrade-contracts/counter/src/errors.rs`. + */ + +/** Discriminator for {@link SwapTradeError} subclasses. */ +export type SwapTradeErrorCode = + | 'CONFIG_INVALID' + | 'ADDRESS_INVALID' + | 'CONTRACT_ID_INVALID' + | 'AMOUNT_INVALID' + | 'SYMBOL_INVALID' + | 'SIGNING_FAILED' + | 'SIMULATION_FAILED' + | 'RPC_FAILED' + | 'TRANSACTION_FAILED' + | 'TRANSACTION_TIMEOUT' + | 'CONTRACT_ERROR'; + +/** Base class for all SDK errors. */ +export class SwapTradeError extends Error { + readonly code: SwapTradeErrorCode; + /** Underlying error, when this wraps a lower-level failure. */ + override readonly cause?: unknown; + + constructor(code: SwapTradeErrorCode, message: string, cause?: unknown) { + super(message); + this.name = new.target.name; + this.code = code; + this.cause = cause; + // Keeps `instanceof` reliable when the package is consumed as ES2022 output. + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Missing or malformed {@link SwapTradeConfig}. */ +export class ConfigError extends SwapTradeError { + constructor(message: string, cause?: unknown) { + super('CONFIG_INVALID', message, cause); + } +} + +/** A supplied value is not a valid Stellar account / contract identifier. */ +export class ValidationError extends SwapTradeError { + constructor( + code: Extract< + SwapTradeErrorCode, + 'ADDRESS_INVALID' | 'CONTRACT_ID_INVALID' | 'AMOUNT_INVALID' | 'SYMBOL_INVALID' + >, + message: string, + cause?: unknown, + ) { + super(code, message, cause); + } +} + +/** The signer rejected the request or returned an unusable signature. */ +export class SigningError extends SwapTradeError { + constructor(message: string, cause?: unknown) { + super('SIGNING_FAILED', message, cause); + } +} + +/** + * `simulateTransaction` reported an error. + * + * Simulation runs before signing, so this means nothing was submitted and no + * fee was charged. + */ +export class SimulationError extends SwapTradeError { + /** Diagnostic events returned by the RPC server, when present. */ + readonly events: readonly string[]; + + constructor(message: string, events: readonly string[] = [], cause?: unknown) { + super('SIMULATION_FAILED', message, cause); + this.events = events; + } +} + +/** Transport-level failure talking to the Soroban RPC server. */ +export class RpcError extends SwapTradeError { + constructor(message: string, cause?: unknown) { + super('RPC_FAILED', message, cause); + } +} + +/** The transaction was submitted but did not reach `SUCCESS`. */ +export class TransactionFailedError extends SwapTradeError { + readonly hash?: string; + readonly status?: string; + + constructor(message: string, hash?: string, status?: string, cause?: unknown) { + super('TRANSACTION_FAILED', message, cause); + this.hash = hash; + this.status = status; + } +} + +/** The transaction did not settle within the configured polling window. */ +export class TransactionTimeoutError extends SwapTradeError { + readonly hash: string; + + constructor(hash: string, timeoutMs: number) { + super( + 'TRANSACTION_TIMEOUT', + `Transaction ${hash} did not settle within ${timeoutMs}ms. It may still be applied; re-check the hash before retrying.`, + ); + this.hash = hash; + } +} + +/** + * The contract itself returned an `Err(...)`. + * + * `contractCode` is the numeric discriminant from the contract's error enum and + * `contractName` is the resolved name when the code is one the SDK knows. + */ +export class ContractCallError extends SwapTradeError { + readonly contractCode?: number; + readonly contractName?: string; + + constructor(message: string, contractCode?: number, contractName?: string, cause?: unknown) { + super('CONTRACT_ERROR', message, cause); + this.contractCode = contractCode; + this.contractName = contractName; + } +} + +/** + * `SwapTradeError` codes as defined in `counter/src/errors.rs`. + * + * Kept in sync manually; the contract is the source of truth. Used only to turn + * an opaque numeric failure into a readable message. + */ +export const CONTRACT_ERROR_NAMES: Readonly> = Object.freeze({ + 1: 'NotAdmin', + 2: 'NotAuthorized', + 3: 'InvalidAddress', + 4: 'InvalidMultiSigConfig', + 5: 'MultiSigNotConfigured', + 10: 'TradingPaused', + 11: 'UserFrozen', + 12: 'CircuitBreakerTripped', + 13: 'InvalidPrivateTransaction', + 90: 'ProposalNotFound', + 91: 'ProposalAlreadyExecuted', + 92: 'AlreadyApproved', + 93: 'InsufficientApprovals', + 94: 'TimelockNotElapsed', + 95: 'AlreadyVoted', + 96: 'InsufficientSignatures', + 97: 'QuorumNotReached', + 98: 'ProposalFailed', + 99: 'ProposalCanceled', + 100: 'InvalidAmount', + 101: 'AmountOverflow', + 102: 'InvalidTokenSymbol', + 103: 'InvalidSwapPair', + 104: 'InsufficientBalance', + 105: 'ZeroAmountSwap', + 200: 'InvariantViolation', + 201: 'StalePrice', + 202: 'InvalidPrice', + 203: 'PriceNotSet', + 204: 'OracleNotConfigured', + 205: 'OracleNotActive', + 206: 'CircuitBreakerActive', + 207: 'CircuitBreakerTriggered', + 208: 'InvalidConfig', + 300: 'RateLimitExceeded', + 301: 'SlippageExceeded', + 302: 'Expired', + 400: 'LPPositionNotFound', + 401: 'InsufficientLPTokens', + 500: 'KYCVerificationRequired', + 501: 'NotKYCOperator', + 502: 'InvalidKYCStateTransition', + 503: 'KYCTerminalStateImmutable', + 504: 'SelfVerificationNotAllowed', + 505: 'KYCOverrideNotFound', + 506: 'KYCTimelockNotElapsed', +}); + +/** Resolve a numeric contract error code to its declared name, if known. */ +export function contractErrorName(code: number): string | undefined { + return CONTRACT_ERROR_NAMES[code]; +} diff --git a/packages/swaptrade-sdk/src/index.ts b/packages/swaptrade-sdk/src/index.ts new file mode 100644 index 0000000..4bd1c59 --- /dev/null +++ b/packages/swaptrade-sdk/src/index.ts @@ -0,0 +1,98 @@ +/** + * `@swaptrade/sdk` — a lightweight TypeScript SDK for the SwapTrade Soroban + * contracts. + * + * @example + * ```ts + * import { SwapTradeClient, NETWORKS, keypairSigner } from '@swaptrade/sdk'; + * + * const client = new SwapTradeClient({ + * ...NETWORKS.local, + * contractId: process.env.SWAPTRADE_CONTRACT_ID!, + * publicKey: process.env.SWAPTRADE_PUBLIC_KEY!, + * signTransaction: keypairSigner(process.env.SWAPTRADE_SECRET_KEY!), + * }); + * + * const { returnValue: orderId } = await client.placeLimitOrder({ + * tokenIn: 'XLM', + * tokenOut: 'USDCSIM', + * amountIn: 1_000n, + * limitPrice: 1_000_000n, + * }); + * ``` + */ + +export { SwapTradeClient } from './client.js'; +export type { ClientOptions, RpcServerLike } from './client.js'; + +export { + DEFAULT_FEE, + DEFAULT_POLL_TIMEOUT_MS, + DEFAULT_TIMEOUT_SECONDS, + assertAccountId, + assertContractId, + assertPositiveAmount, + assertSymbol, + networkPreset, + resolveConfig, +} from './config.js'; + +export { + CONTRACT_ERROR_NAMES, + ConfigError, + ContractCallError, + RpcError, + SigningError, + SimulationError, + SwapTradeError, + TransactionFailedError, + TransactionTimeoutError, + ValidationError, + contractErrorName, +} from './errors.js'; +export type { SwapTradeErrorCode } from './errors.js'; + +export { browserWalletSigner, keypairSigner } from './signers.js'; +export type { BrowserWallet } from './signers.js'; + +export { + KYC_STATUSES, + NETWORKS, + ORDER_STATUSES, + ORDER_TYPES, +} from './types.js'; +export type { + KYCStatus, + NetworkName, + Order, + OrderStatus, + OrderType, + PlaceLimitOrderParams, + PortfolioSummary, + ResolvedConfig, + SignTransaction, + SimulationResult, + SwapTradeConfig, + SwapParams, + TransactionResult, +} from './types.js'; + +export { + addressToScVal, + decodeKycStatus, + decodeOrder, + decodeOrderStatus, + decodeOrderType, + decodePortfolio, + fromScVal, + i128ToScVal, + kycStatusToScVal, + optionToScVal, + parseContractErrorCode, + symbolToScVal, + tupleToScVal, + u128ToScVal, + u32ToScVal, + u64ToScVal, + unitEnumToScVal, +} from './scval.js'; diff --git a/packages/swaptrade-sdk/src/scval.ts b/packages/swaptrade-sdk/src/scval.ts new file mode 100644 index 0000000..54e0164 --- /dev/null +++ b/packages/swaptrade-sdk/src/scval.ts @@ -0,0 +1,241 @@ +/** + * Conversion between TypeScript values and Soroban `ScVal`s. + * + * This is the only module that needs to know how the contract encodes its + * arguments and return values, so the mapping stays reviewable in one place. + */ +import { Address, nativeToScVal, scValToNative, xdr } from '@stellar/stellar-sdk'; +import { assertAccountId, assertSymbol } from './config.js'; +import { ContractCallError, contractErrorName } from './errors.js'; +import { + KYC_STATUSES, + ORDER_STATUSES, + ORDER_TYPES, + type KYCStatus, + type Order, + type OrderStatus, + type OrderType, + type PortfolioSummary, +} from './types.js'; + +/** Encode an account or contract address as an `ScVal`. */ +export function addressToScVal(value: string): xdr.ScVal { + return new Address(value).toScVal(); +} + +/** Encode a Soroban `Symbol`. */ +export function symbolToScVal(value: string, label = 'symbol', short = true): xdr.ScVal { + return nativeToScVal(assertSymbol(value, label, short), { type: 'symbol' }); +} + +/** Encode a signed 128-bit integer (`i128`). */ +export function i128ToScVal(value: bigint): xdr.ScVal { + return nativeToScVal(value, { type: 'i128' }); +} + +/** Encode an unsigned 128-bit integer (`u128`). */ +export function u128ToScVal(value: bigint): xdr.ScVal { + return nativeToScVal(value, { type: 'u128' }); +} + +/** Encode an unsigned 64-bit integer (`u64`). */ +export function u64ToScVal(value: bigint): xdr.ScVal { + return nativeToScVal(value, { type: 'u64' }); +} + +/** Encode an unsigned 32-bit integer (`u32`). */ +export function u32ToScVal(value: number): xdr.ScVal { + return nativeToScVal(value, { type: 'u32' }); +} + +/** + * Encode a Rust `Option`. + * + * `Some(v)` is the inner value and `None` is `ScVal::Void`, which is how the + * Soroban host represents optionals. + */ +export function optionToScVal( + value: T | undefined | null, + encode: (inner: T) => xdr.ScVal, +): xdr.ScVal { + return value === undefined || value === null ? xdr.ScVal.scvVoid() : encode(value); +} + +/** + * Encode a fieldless Rust enum variant. + * + * `#[contracttype]` encodes these as a single-element vector holding the variant + * name as a symbol. + */ +export function unitEnumToScVal(variant: string): xdr.ScVal { + return xdr.ScVal.scvVec([nativeToScVal(variant, { type: 'symbol' })]); +} + +/** + * Encode a Rust tuple, e.g. the `(Symbol, Symbol)` token pair used by the + * oracle entry points. Tuples are encoded as a vector of their elements. + */ +export function tupleToScVal(elements: xdr.ScVal[]): xdr.ScVal { + return xdr.ScVal.scvVec(elements); +} + +/** Encode a `KYCStatus` argument. */ +export function kycStatusToScVal(status: KYCStatus): xdr.ScVal { + if (!KYC_STATUSES.includes(status)) { + throw new ContractCallError( + `Unknown KYC status "${status}". Expected one of: ${KYC_STATUSES.join(', ')}.`, + ); + } + return unitEnumToScVal(status); +} + +/** Decode an `ScVal` into a plain JavaScript value. */ +export function fromScVal(value: xdr.ScVal): T { + return scValToNative(value) as T; +} + +function asBigInt(value: unknown): bigint { + if (typeof value === 'bigint') return value; + if (typeof value === 'number') return BigInt(value); + if (typeof value === 'string' && value !== '') return BigInt(value); + return 0n; +} + +function optionalBigInt(value: unknown): bigint | undefined { + return value === undefined || value === null ? undefined : asBigInt(value); +} + +/** + * Normalise a decoded unit-enum value to one of `allowed`. + * + * `scValToNative` yields either the variant name or a single-element array + * depending on the encoding, and an index when the enum carries explicit + * discriminants — all three are handled here. + */ +function decodeUnitEnum( + value: unknown, + allowed: readonly T[], + label: string, +): T { + const raw = Array.isArray(value) ? value[0] : value; + + if (typeof raw === 'string' && (allowed as readonly string[]).includes(raw)) { + return raw as T; + } + if (typeof raw === 'number' || typeof raw === 'bigint') { + const variant = allowed[Number(raw)]; + if (variant) return variant; + } + throw new ContractCallError( + `Could not decode ${label} from contract value ${JSON.stringify(String(raw))}.`, + ); +} + +/** Decode an `OrderStatus`. */ +export function decodeOrderStatus(value: unknown): OrderStatus { + return decodeUnitEnum(value, ORDER_STATUSES, 'order status'); +} + +/** Decode an `OrderType`. */ +export function decodeOrderType(value: unknown): OrderType { + return decodeUnitEnum(value, ORDER_TYPES, 'order type'); +} + +/** Decode a `KYCStatus`. */ +export function decodeKycStatus(value: unknown): KYCStatus { + return decodeUnitEnum(value, KYC_STATUSES, 'KYC status'); +} + +/** + * Decode the contract's `Order` struct into an {@link Order}. + * + * Accepts an already-native object so it can be unit-tested without building + * XDR by hand. + */ +export function decodeOrder(raw: unknown): Order { + if (raw === null || typeof raw !== 'object') { + throw new ContractCallError('Expected an order struct from the contract.'); + } + const o = raw as Record; + + const owner = o['owner']; + const order: Order = { + orderId: asBigInt(o['order_id']), + owner: typeof owner === 'string' ? owner : String(owner ?? ''), + orderType: decodeOrderType(o['order_type']), + tokenIn: String(o['token_in'] ?? ''), + tokenOut: String(o['token_out'] ?? ''), + amountIn: asBigInt(o['amount_in']), + amountFilled: asBigInt(o['amount_filled']), + status: decodeOrderStatus(o['status']), + createdAt: asBigInt(o['created_at']), + }; + + // Optional contract fields are only set when present, so consumers can rely + // on `undefined` meaning "None" rather than "zero". + const limitPrice = optionalBigInt(o['limit_price']); + if (limitPrice !== undefined) order.limitPrice = limitPrice; + const triggerPrice = optionalBigInt(o['trigger_price']); + if (triggerPrice !== undefined) order.triggerPrice = triggerPrice; + const expiresAt = optionalBigInt(o['expires_at']); + if (expiresAt !== undefined) order.expiresAt = expiresAt; + const filledAt = optionalBigInt(o['filled_at']); + if (filledAt !== undefined) order.filledAt = filledAt; + const intervalSecs = optionalBigInt(o['interval_secs']); + if (intervalSecs !== undefined) order.intervalSecs = intervalSecs; + const remaining = optionalBigInt(o['remaining_occurrences']); + if (remaining !== undefined) order.remainingOccurrences = remaining; + const nextRun = optionalBigInt(o['next_run']); + if (nextRun !== undefined) order.nextRun = nextRun; + + return order; +} + +/** Decode the `(u32, i128)` tuple returned by `get_portfolio`. */ +export function decodePortfolio(raw: unknown): PortfolioSummary { + if (!Array.isArray(raw) || raw.length < 2) { + throw new ContractCallError( + 'Expected get_portfolio to return a (trade_count, total_volume) tuple.', + ); + } + return { + tradeCount: Number(asBigInt(raw[0])), + totalVolume: asBigInt(raw[1]), + }; +} + +/** + * Extract a contract error code from an RPC/simulation error payload. + * + * The host reports these as `Error(Contract, #N)`; returns `undefined` when the + * message is not a contract error. + */ +export function parseContractErrorCode(message: string): number | undefined { + const match = /Error\(Contract,\s*#(\d+)\)/.exec(message); + if (match?.[1]) return Number(match[1]); + const alt = /ContractError\((\d+)\)/.exec(message); + return alt?.[1] ? Number(alt[1]) : undefined; +} + +/** + * Turn a raw host error message into a {@link ContractCallError} when it encodes + * a contract error, otherwise return `undefined` so the caller can classify it. + */ +export function asContractError(message: string, cause?: unknown): ContractCallError | undefined { + const code = parseContractErrorCode(message); + if (code === undefined) return undefined; + const name = contractErrorName(code); + return new ContractCallError( + name + ? `Contract returned ${name} (code ${code}).` + : `Contract returned error code ${code}.`, + code, + name, + cause, + ); +} + +/** Validate and encode an account argument in one step. */ +export function accountArg(value: string, label = 'address'): xdr.ScVal { + return addressToScVal(assertAccountId(value, label)); +} diff --git a/packages/swaptrade-sdk/src/signers.ts b/packages/swaptrade-sdk/src/signers.ts new file mode 100644 index 0000000..aa6a7f2 --- /dev/null +++ b/packages/swaptrade-sdk/src/signers.ts @@ -0,0 +1,73 @@ +/** + * Signer helpers. + * + * The SDK accepts any {@link SignTransaction} callback, which keeps wallet + * choice out of the client. These helpers cover the two cases the example app + * and the localnet scripts need. + */ +import { Keypair, TransactionBuilder } from '@stellar/stellar-sdk'; +import { SigningError, ValidationError } from './errors.js'; +import type { SignTransaction } from './types.js'; + +/** + * Build a signer from a Stellar secret key. + * + * Intended for localnet demos, scripts and tests. Never ship a secret key to a + * browser bundle — use {@link browserWalletSigner} for user-facing apps. + * + * @param secretKey - A Stellar secret seed (`S...`). + * @throws {ValidationError} when the secret key is malformed. + */ +export function keypairSigner(secretKey: string): SignTransaction { + let keypair: Keypair; + try { + keypair = Keypair.fromSecret(secretKey); + } catch (cause) { + throw new ValidationError( + 'ADDRESS_INVALID', + 'Invalid secret key: expected a Stellar secret seed starting with "S".', + cause, + ); + } + + return (xdr, { networkPassphrase }) => { + try { + const tx = TransactionBuilder.fromXDR(xdr, networkPassphrase); + tx.sign(keypair); + return tx.toXDR(); + } catch (cause) { + throw new SigningError(`Local keypair could not sign the transaction: ${String(cause)}`, cause); + } + }; +} + +/** Minimal shape of a Freighter-style injected browser wallet. */ +export interface BrowserWallet { + signTransaction( + xdr: string, + opts: { networkPassphrase?: string; address?: string }, + ): Promise; +} + +/** + * Adapt a Freighter-style browser wallet to {@link SignTransaction}. + * + * Recent Freighter versions resolve to `{ signedTxXdr }` while older ones + * resolve to a bare string; both are accepted. + */ +export function browserWalletSigner(wallet: BrowserWallet): SignTransaction { + return async (xdr, context) => { + const result = await wallet.signTransaction(xdr, { + networkPassphrase: context.networkPassphrase, + address: context.address, + }); + + const signed = typeof result === 'string' ? result : result?.signedTxXdr; + if (typeof signed !== 'string' || signed.trim() === '') { + throw new SigningError( + 'Wallet did not return signed transaction XDR. The request may have been rejected.', + ); + } + return signed; + }; +} diff --git a/packages/swaptrade-sdk/src/types.ts b/packages/swaptrade-sdk/src/types.ts new file mode 100644 index 0000000..4268358 --- /dev/null +++ b/packages/swaptrade-sdk/src/types.ts @@ -0,0 +1,201 @@ +/** + * Public types for the SwapTrade SDK. + * + * Shapes here mirror the on-chain types in `swaptrade-contracts/counter`: + * - `Order` / `OrderType` / `OrderStatus` -> `counter/src/orders.rs` + * - `KYCStatus` -> `counter/src/kyc.rs` + * - network defaults -> `soroban.toml` + */ + +/** Well-known network presets, taken verbatim from `soroban.toml`. */ +export const NETWORKS = Object.freeze({ + local: Object.freeze({ + rpcUrl: 'http://localhost:8000/soroban/rpc', + networkPassphrase: 'Standalone Network ; February 2017', + }), + testnet: Object.freeze({ + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + }), + mainnet: Object.freeze({ + rpcUrl: 'https://soroban.stellar.org', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + }), +}); + +/** Name of a preset in {@link NETWORKS}. */ +export type NetworkName = keyof typeof NETWORKS; + +/** + * Signs a transaction envelope. + * + * The XDR string in and out keeps the SDK agnostic about *how* signing happens, + * so a Freighter-style browser wallet and a local keypair satisfy the same + * interface. Implementations must reject rather than return unsigned XDR. + * + * @param xdr - Base64 transaction envelope to sign. + * @param context - Network the transaction is bound to. + * @returns The signed envelope as base64 XDR. + */ +export type SignTransaction = ( + xdr: string, + context: { networkPassphrase: string; address: string }, +) => Promise | string; + +/** Configuration for {@link SwapTradeClient}. */ +export interface SwapTradeConfig { + /** Soroban RPC endpoint. Required; never defaulted to a public network. */ + rpcUrl: string; + /** Network passphrase the RPC server is running. */ + networkPassphrase: string; + /** Contract ID (`C...`) of the deployed SwapTrade contract. */ + contractId: string; + /** Public key (`G...`) used as source account for calls. */ + publicKey: string; + /** Signer callback. Read-only queries work without it. */ + signTransaction?: SignTransaction; + /** + * Whether to allow plain-HTTP RPC URLs. + * Defaults to `true` only for loopback hosts, so localnet works out of the + * box while a non-TLS remote endpoint is rejected. + */ + allowHttp?: boolean; + /** Fee in stroops offered per operation. Defaults to 1_000_000 (0.1 XLM). */ + fee?: string; + /** Seconds the built transaction stays valid. Defaults to 60. */ + timeoutSeconds?: number; + /** Milliseconds to wait for a submitted transaction to settle. Defaults to 30_000. */ + pollTimeoutMs?: number; +} + +/** Fully-resolved configuration, after defaults are applied and validated. */ +export interface ResolvedConfig { + readonly rpcUrl: string; + readonly networkPassphrase: string; + readonly contractId: string; + readonly publicKey: string; + readonly signTransaction?: SignTransaction; + readonly allowHttp: boolean; + readonly fee: string; + readonly timeoutSeconds: number; + readonly pollTimeoutMs: number; +} + +/** Result of a state-changing contract call. */ +export interface TransactionResult { + /** Transaction hash, present once submitted. */ + hash: string; + /** Final RPC status, e.g. `SUCCESS`. */ + status: string; + /** Decoded contract return value, when the call returns one. */ + returnValue?: T; + /** Ledger the transaction was applied in, when reported. */ + ledger?: number; +} + +/** Outcome of a simulate-only call. */ +export interface SimulationResult { + /** Decoded return value produced by simulation. */ + returnValue: T; + /** Minimum resource fee the RPC server computed, in stroops. */ + minResourceFee?: string; +} + +/** + * Order lifecycle states, mirroring `OrderStatus` in `counter/src/orders.rs`. + * + * Declared in the contract's variant order because Soroban encodes unit enum + * variants by index. + */ +export const ORDER_STATUSES = [ + 'Pending', + 'Filled', + 'Cancelled', + 'Expired', + 'PartiallyFilled', + 'Scheduled', +] as const; + +export type OrderStatus = (typeof ORDER_STATUSES)[number]; + +/** Order kinds, mirroring `OrderType` in `counter/src/orders.rs`. */ +export const ORDER_TYPES = ['Market', 'Limit', 'StopLoss', 'StopLimit'] as const; + +export type OrderType = (typeof ORDER_TYPES)[number]; + +/** + * An on-chain order, mirroring `Order` in `counter/src/orders.rs`. + * + * `i128` and `u128` contract fields are surfaced as `bigint` to avoid the + * precision loss a `number` would introduce. + */ +export interface Order { + orderId: bigint; + owner: string; + orderType: OrderType; + tokenIn: string; + tokenOut: string; + amountIn: bigint; + amountFilled: bigint; + limitPrice?: bigint; + triggerPrice?: bigint; + status: OrderStatus; + createdAt: bigint; + expiresAt?: bigint; + filledAt?: bigint; + intervalSecs?: bigint; + remainingOccurrences?: bigint; + nextRun?: bigint; +} + +/** + * KYC states, mirroring `KYCStatus` in `counter/src/kyc.rs`. + * + * The contract assigns explicit discriminants `0..=5`; this array is ordered to + * match so the index is the discriminant. `Verified` and `Rejected` are terminal + * states in the contract's state machine. + */ +export const KYC_STATUSES = [ + 'Unverified', + 'Pending', + 'InReview', + 'AdditionalInfoRequired', + 'Verified', + 'Rejected', +] as const; + +export type KYCStatus = (typeof KYC_STATUSES)[number]; + +/** Portfolio summary returned by `get_portfolio` as a `(u32, i128)` tuple. */ +export interface PortfolioSummary { + /** Number of trades recorded for the account. */ + tradeCount: number; + /** Total traded volume. */ + totalVolume: bigint; +} + +/** Parameters for placing a limit order via `place_limit_order`. */ +export interface PlaceLimitOrderParams { + /** Symbol being sold, e.g. `XLM`. Max 9 characters (Soroban `Symbol`). */ + tokenIn: string; + /** Symbol being bought, e.g. `USDCSIM`. */ + tokenOut: string; + /** Amount of `tokenIn` to sell. Must be positive. */ + amountIn: bigint; + /** Minimum acceptable price, scaled by the contract's `PRECISION`. */ + limitPrice: bigint; + /** Optional expiry as a unix timestamp; omit for no expiry. */ + expiresAt?: bigint; + /** Account placing the order. Defaults to the configured public key. */ + user?: string; +} + +/** Parameters for `swap` / `safe_swap`. */ +export interface SwapParams { + from: string; + to: string; + amount: bigint; + user?: string; + /** Only used by `safe_swap`: unix timestamp after which the swap is void. */ + deadline?: bigint; +} diff --git a/packages/swaptrade-sdk/test/client.test.ts b/packages/swaptrade-sdk/test/client.test.ts new file mode 100644 index 0000000..02368a3 --- /dev/null +++ b/packages/swaptrade-sdk/test/client.test.ts @@ -0,0 +1,425 @@ +/** + * Client behaviour: argument mapping, the create -> fund -> accept path, and + * failure handling. + * + * A fake RPC server is injected at the `RpcServerLike` seam. Everything above it + * — transaction building, simulation handling, signing, submission and polling — + * is the real implementation, so these tests exercise the SDK rather than a mock + * of it. No network calls are made. + */ +import { Address, TransactionBuilder, scValToNative } from '@stellar/stellar-sdk'; +import { describe, expect, it } from 'vitest'; +import { + ConfigError, + ContractCallError, + RpcError, + SigningError, + SimulationError, + SwapTradeClient, + TransactionFailedError, + TransactionTimeoutError, + ValidationError, +} from '../src/index.js'; +import { + TEST_CONTRACT_ID, + TEST_PUBLIC_KEY, + baseConfig, + createFakeServer, + i128Return, + simulationFailure, + simulationSuccess, + u64Return, +} from './helpers.js'; + +/** + * Decode the invocation the client built, so tests can assert on the exact + * method name and arguments that would reach the contract. + */ +function decodeInvocation(tx: { toXDR(): string }, networkPassphrase: string) { + const parsed = TransactionBuilder.fromXDR(tx.toXDR(), networkPassphrase) as never; + const op = (parsed as { operations: unknown[] }).operations[0] as { + func: { + invokeContract(): { + functionName(): { toString(): string }; + args(): unknown[]; + contractAddress(): unknown; + }; + }; + }; + const invoke = op.func.invokeContract(); + return { + method: invoke.functionName().toString(), + args: invoke.args().map((a) => scValToNative(a as never)), + contract: Address.fromScAddress(invoke.contractAddress() as never).toString(), + }; +} + +describe('buildTransaction', () => { + it('targets the configured contract and encodes arguments in ABI order', async () => { + const server = createFakeServer(); + const client = new SwapTradeClient(baseConfig(), { server }); + + const tx = await client.buildTransaction('balance_of', []); + expect(decodeInvocation(tx, client.config.networkPassphrase).contract).toBe( + TEST_CONTRACT_ID, + ); + expect(server.getAccount).toHaveBeenCalledWith(TEST_PUBLIC_KEY); + }); + + it('reports a helpful error when the source account cannot be loaded', async () => { + const server = createFakeServer({ accountError: new Error('Account not found') }); + const client = new SwapTradeClient(baseConfig(), { server }); + + await expect(client.buildTransaction('initialize')).rejects.toThrow(RpcError); + await expect(client.buildTransaction('initialize')).rejects.toThrow( + /Could not load source account/, + ); + }); +}); + +describe('read-only calls use simulation', () => { + it('balanceOf maps (token, user) and decodes an i128 without precision loss', async () => { + const huge = 170141183460469231731687303715884105727n; + const server = createFakeServer({ simulateResult: simulationSuccess(i128Return(huge)) }); + const client = new SwapTradeClient(baseConfig(), { server }); + + await expect(client.balanceOf('USDCSIM')).resolves.toBe(huge); + + const tx = server.simulateTransaction.mock.calls[0]![0] as { toXDR(): string }; + const { method, args } = decodeInvocation(tx, client.config.networkPassphrase); + expect(method).toBe('balance_of'); + expect(args).toEqual(['USDCSIM', TEST_PUBLIC_KEY]); + // Read-only paths must never submit. + expect(server.sendTransaction).not.toHaveBeenCalled(); + }); + + it('getPortfolio decodes the (u32, i128) tuple', async () => { + const server = createFakeServer({ + simulateResult: simulationSuccess( + (await import('@stellar/stellar-sdk')).xdr.ScVal.scvVec([ + (await import('@stellar/stellar-sdk')).nativeToScVal(3, { type: 'u32' }), + i128Return(2_500n), + ]), + ), + }); + const client = new SwapTradeClient(baseConfig(), { server }); + + await expect(client.getPortfolio()).resolves.toEqual({ + tradeCount: 3, + totalVolume: 2_500n, + }); + }); + + it('surfaces a contract error code from a failed simulation', async () => { + // 500 is KYCVerificationRequired in counter/src/errors.rs. + const server = createFakeServer({ + simulateResult: simulationFailure('HostError: Error(Contract, #500)'), + }); + const client = new SwapTradeClient(baseConfig(), { server }); + + const error = await client.balanceOf('XLM').catch((e: unknown) => e); + expect(error).toBeInstanceOf(ContractCallError); + expect((error as ContractCallError).contractCode).toBe(500); + expect((error as ContractCallError).contractName).toBe('KYCVerificationRequired'); + }); + + it('reports a non-contract simulation failure as SimulationError', async () => { + const server = createFakeServer({ + simulateResult: simulationFailure('resource limit exceeded'), + }); + const client = new SwapTradeClient(baseConfig(), { server }); + + await expect(client.balanceOf('XLM')).rejects.toThrow(SimulationError); + }); +}); + +describe('create -> fund -> accept', () => { + it('CREATE: placeLimitOrder maps every argument and returns the new order ID', async () => { + const server = createFakeServer({ + simulateResult: simulationSuccess(u64Return(7n)), + getTransactionResults: [{ status: 'SUCCESS', ledger: 42, returnValue: u64Return(7n) }], + }); + const client = new SwapTradeClient(baseConfig(), { server }); + + const result = await client.placeLimitOrder({ + tokenIn: 'XLM', + tokenOut: 'USDCSIM', + amountIn: 1_000n, + limitPrice: 1_000_000n, + expiresAt: 1_800_000_000n, + }); + + expect(result.returnValue).toBe(7n); + expect(result.status).toBe('SUCCESS'); + expect(result.ledger).toBe(42); + + const tx = server.simulateTransaction.mock.calls[0]![0] as { toXDR(): string }; + const { method, args } = decodeInvocation(tx, client.config.networkPassphrase); + expect(method).toBe('place_limit_order'); + // Order matches place_limit_order in counter/src/lib.rs. + expect(args).toEqual([ + 'XLM', + 'USDCSIM', + 1_000n, + 1_000_000n, + 1_800_000_000n, + TEST_PUBLIC_KEY, + ]); + }); + + it('CREATE: omitting expiresAt encodes Option::None as void', async () => { + const server = createFakeServer({ simulateResult: simulationSuccess(u64Return(1n)) }); + const client = new SwapTradeClient(baseConfig(), { server }); + + await client.placeLimitOrder({ + tokenIn: 'XLM', + tokenOut: 'USDCSIM', + amountIn: 5n, + limitPrice: 10n, + }); + + const tx = server.simulateTransaction.mock.calls[0]![0] as { toXDR(): string }; + const { args } = decodeInvocation(tx, client.config.networkPassphrase); + // scValToNative maps ScVal::Void to null. + expect(args[4]).toBeNull(); + }); + + it('FUND: mint maps (token, to, amount)', async () => { + const server = createFakeServer(); + const client = new SwapTradeClient(baseConfig(), { server }); + + await client.mint('USDCSIM', TEST_PUBLIC_KEY, 5_000n); + + const tx = server.simulateTransaction.mock.calls[0]![0] as { toXDR(): string }; + const { method, args } = decodeInvocation(tx, client.config.networkPassphrase); + expect(method).toBe('mint'); + expect(args).toEqual(['USDCSIM', TEST_PUBLIC_KEY, 5_000n]); + }); + + it('ACCEPT: executeDueOrders decodes the returned Vec', async () => { + const { xdr, nativeToScVal } = await import('@stellar/stellar-sdk'); + const ids = xdr.ScVal.scvVec([ + nativeToScVal(7n, { type: 'u64' }), + nativeToScVal(8n, { type: 'u64' }), + ]); + const server = createFakeServer({ + simulateResult: simulationSuccess(ids), + getTransactionResults: [{ status: 'SUCCESS', ledger: 43, returnValue: ids }], + }); + const client = new SwapTradeClient(baseConfig(), { server }); + + const result = await client.executeDueOrders(); + expect(result.returnValue).toEqual([7n, 8n]); + + const tx = server.simulateTransaction.mock.calls[0]![0] as { toXDR(): string }; + expect(decodeInvocation(tx, client.config.networkPassphrase).method).toBe( + 'execute_due_orders', + ); + }); + + it('signs, submits and polls exactly once for a successful call', async () => { + const server = createFakeServer(); + const client = new SwapTradeClient(baseConfig(), { server }); + + await client.mint('XLM', TEST_PUBLIC_KEY, 1n); + + expect(server.simulateTransaction).toHaveBeenCalledTimes(1); + expect(server.sendTransaction).toHaveBeenCalledTimes(1); + expect(server.getTransaction).toHaveBeenCalledTimes(1); + }); + + it('polls until the transaction leaves NOT_FOUND', async () => { + const server = createFakeServer({ + getTransactionResults: [ + { status: 'NOT_FOUND' }, + { status: 'SUCCESS', ledger: 44 }, + ], + }); + const client = new SwapTradeClient(baseConfig({ pollTimeoutMs: 5_000 }), { server }); + + await expect(client.mint('XLM', TEST_PUBLIC_KEY, 1n)).resolves.toMatchObject({ + status: 'SUCCESS', + }); + expect(server.getTransaction).toHaveBeenCalledTimes(2); + }); +}); + +describe('argument validation happens before any network call', () => { + it('rejects a non-positive amount', async () => { + const server = createFakeServer(); + const client = new SwapTradeClient(baseConfig(), { server }); + + await expect(client.mint('XLM', TEST_PUBLIC_KEY, 0n)).rejects.toThrow(ValidationError); + expect(server.simulateTransaction).not.toHaveBeenCalled(); + }); + + it('rejects an over-length token symbol', async () => { + const client = new SwapTradeClient(baseConfig(), { server: createFakeServer() }); + await expect(client.balanceOf('WAYTOOLONGSYMBOL')).rejects.toThrow(/at most 9/); + }); + + it('rejects an invalid recipient address', async () => { + const client = new SwapTradeClient(baseConfig(), { server: createFakeServer() }); + await expect(client.mint('XLM', 'not-an-address', 1n)).rejects.toThrow( + /not a valid Stellar account ID/, + ); + }); + + it('rejects swapping a token for itself', async () => { + const client = new SwapTradeClient(baseConfig(), { server: createFakeServer() }); + await expect(client.swap('XLM', 'XLM', 10n)).rejects.toThrow(/for itself/); + }); + + it('rejects out-of-range slippage', async () => { + const client = new SwapTradeClient(baseConfig(), { server: createFakeServer() }); + await expect(client.setMaxSlippageBps(10_001)).rejects.toThrow(ContractCallError); + }); +}); + +describe('signing and submission failures', () => { + it('requires a signer for state-changing calls', async () => { + const client = new SwapTradeClient( + baseConfig({ signTransaction: undefined }), + { server: createFakeServer() }, + ); + + await expect(client.mint('XLM', TEST_PUBLIC_KEY, 1n)).rejects.toThrow(ConfigError); + await expect(client.mint('XLM', TEST_PUBLIC_KEY, 1n)).rejects.toThrow( + /no signTransaction callback/, + ); + }); + + it('still allows read-only calls without a signer', async () => { + const server = createFakeServer({ simulateResult: simulationSuccess(i128Return(9n)) }); + const client = new SwapTradeClient(baseConfig({ signTransaction: undefined }), { server }); + + await expect(client.balanceOf('XLM')).resolves.toBe(9n); + }); + + it('wraps a signer rejection as SigningError', async () => { + const client = new SwapTradeClient( + baseConfig({ + signTransaction: () => { + throw new Error('User declined the request'); + }, + }), + { server: createFakeServer() }, + ); + + const error = await client.mint('XLM', TEST_PUBLIC_KEY, 1n).catch((e: unknown) => e); + expect(error).toBeInstanceOf(SigningError); + expect((error as SigningError).message).toMatch(/User declined/); + }); + + it('rejects a signer that returns empty XDR', async () => { + const client = new SwapTradeClient( + baseConfig({ signTransaction: () => '' }), + { server: createFakeServer() }, + ); + + await expect(client.mint('XLM', TEST_PUBLIC_KEY, 1n)).rejects.toThrow( + /must return the signed envelope/, + ); + }); + + it('rejects a signer that returns unparseable XDR', async () => { + const client = new SwapTradeClient( + baseConfig({ signTransaction: () => 'this-is-not-xdr' }), + { server: createFakeServer() }, + ); + + await expect(client.mint('XLM', TEST_PUBLIC_KEY, 1n)).rejects.toThrow( + /could not be parsed/, + ); + }); + + it('reports a network-level rejection', async () => { + const server = createFakeServer({ + sendResult: { status: 'ERROR', hash: 'b'.repeat(64), errorResult: 'txInsufficientFee' }, + }); + const client = new SwapTradeClient(baseConfig(), { server }); + + const error = await client.mint('XLM', TEST_PUBLIC_KEY, 1n).catch((e: unknown) => e); + expect(error).toBeInstanceOf(TransactionFailedError); + expect((error as TransactionFailedError).status).toBe('ERROR'); + }); + + it('reports an on-chain failure after submission', async () => { + const server = createFakeServer({ + getTransactionResults: [{ status: 'FAILED', resultXdr: 'txFailed' }], + }); + const client = new SwapTradeClient(baseConfig(), { server }); + + await expect(client.mint('XLM', TEST_PUBLIC_KEY, 1n)).rejects.toThrow( + TransactionFailedError, + ); + }); + + it('maps an on-chain contract error to ContractCallError', async () => { + const server = createFakeServer({ + getTransactionResults: [ + { status: 'FAILED', resultXdr: 'HostError: Error(Contract, #300)' }, + ], + }); + const client = new SwapTradeClient(baseConfig(), { server }); + + const error = await client.mint('XLM', TEST_PUBLIC_KEY, 1n).catch((e: unknown) => e); + expect(error).toBeInstanceOf(ContractCallError); + // 300 is RateLimitExceeded. + expect((error as ContractCallError).contractName).toBe('RateLimitExceeded'); + }); + + it('times out rather than polling forever, and reports the hash', async () => { + const server = createFakeServer({ getTransactionResults: [{ status: 'NOT_FOUND' }] }); + const client = new SwapTradeClient(baseConfig({ pollTimeoutMs: 1 }), { server }); + + const error = await client.mint('XLM', TEST_PUBLIC_KEY, 1n).catch((e: unknown) => e); + expect(error).toBeInstanceOf(TransactionTimeoutError); + expect((error as TransactionTimeoutError).hash).toBe('a'.repeat(64)); + }); + + it('wraps an RPC transport failure as RpcError', async () => { + const server = createFakeServer({ sendError: new Error('ECONNREFUSED') }); + const client = new SwapTradeClient(baseConfig(), { server }); + + await expect(client.mint('XLM', TEST_PUBLIC_KEY, 1n)).rejects.toThrow(RpcError); + }); +}); + +describe('KYC helpers', () => { + it('encodes a KYCStatus enum variant and an optional reason', async () => { + const server = createFakeServer(); + const client = new SwapTradeClient(baseConfig(), { server }); + + await client.kycUpdateStatus(TEST_PUBLIC_KEY, TEST_PUBLIC_KEY, 'Verified'); + + const tx = server.simulateTransaction.mock.calls[0]![0] as { toXDR(): string }; + const { method, args } = decodeInvocation(tx, client.config.networkPassphrase); + expect(method).toBe('kyc_update_status'); + // Unit enum variants decode to a single-element array. + expect(args[2]).toEqual(['Verified']); + expect(args[3]).toBeNull(); + }); + + it('rejects an unknown KYC status', async () => { + const client = new SwapTradeClient(baseConfig(), { server: createFakeServer() }); + await expect( + client.kycUpdateStatus(TEST_PUBLIC_KEY, TEST_PUBLIC_KEY, 'Approved' as never), + ).rejects.toThrow(/Unknown KYC status/); + }); +}); + +describe('oracle price helpers', () => { + it('encodes the (Symbol, Symbol) token pair as a tuple', async () => { + const server = createFakeServer(); + const client = new SwapTradeClient(baseConfig(), { server }); + + await client.setPrice('XLM', 'USDCSIM', 1_000_000n); + + const tx = server.simulateTransaction.mock.calls[0]![0] as { toXDR(): string }; + const { method, args } = decodeInvocation(tx, client.config.networkPassphrase); + expect(method).toBe('set_price'); + expect(args[0]).toEqual(['XLM', 'USDCSIM']); + expect(args[1]).toBe(1_000_000n); + }); +}); diff --git a/packages/swaptrade-sdk/test/config.test.ts b/packages/swaptrade-sdk/test/config.test.ts new file mode 100644 index 0000000..dde656a --- /dev/null +++ b/packages/swaptrade-sdk/test/config.test.ts @@ -0,0 +1,182 @@ +/** + * Configuration validation. + * + * These tests pin the contract that the SDK never silently guesses a network, + * contract or identity, because a wrong default here means signing against the + * wrong chain. + */ +import { describe, expect, it } from 'vitest'; +import { + ConfigError, + DEFAULT_FEE, + DEFAULT_POLL_TIMEOUT_MS, + DEFAULT_TIMEOUT_SECONDS, + NETWORKS, + SwapTradeClient, + ValidationError, + assertPositiveAmount, + assertSymbol, + networkPreset, + resolveConfig, +} from '../src/index.js'; +import { TEST_CONTRACT_ID, TEST_PUBLIC_KEY, baseConfig } from './helpers.js'; + +describe('resolveConfig', () => { + it('applies documented defaults for optional fields', () => { + const resolved = resolveConfig({ + rpcUrl: NETWORKS.local.rpcUrl, + networkPassphrase: NETWORKS.local.networkPassphrase, + contractId: TEST_CONTRACT_ID, + publicKey: TEST_PUBLIC_KEY, + }); + + expect(resolved.fee).toBe(DEFAULT_FEE); + expect(resolved.timeoutSeconds).toBe(DEFAULT_TIMEOUT_SECONDS); + expect(resolved.pollTimeoutMs).toBe(DEFAULT_POLL_TIMEOUT_MS); + }); + + it('returns a frozen object so config cannot drift after construction', () => { + const resolved = resolveConfig(baseConfig()); + expect(Object.isFrozen(resolved)).toBe(true); + }); + + it.each([ + ['missing config entirely', undefined], + ['a null config', null], + ])('rejects %s', (_label, value) => { + expect(() => resolveConfig(value as never)).toThrow(ConfigError); + }); + + it('rejects a missing rpcUrl instead of defaulting to a public network', () => { + expect(() => resolveConfig(baseConfig({ rpcUrl: undefined as never }))).toThrow( + /Missing "rpcUrl"/, + ); + }); + + it('rejects a malformed rpcUrl', () => { + expect(() => resolveConfig(baseConfig({ rpcUrl: 'not-a-url' }))).toThrow( + /not a valid URL/, + ); + }); + + it('rejects a non-HTTP protocol', () => { + expect(() => resolveConfig(baseConfig({ rpcUrl: 'ftp://example.org' }))).toThrow( + /is not supported/, + ); + }); + + it('rejects a missing networkPassphrase, which would produce invalid signatures', () => { + expect(() => + resolveConfig(baseConfig({ networkPassphrase: undefined as never })), + ).toThrow(/Missing "networkPassphrase"/); + }); + + it('allows plain HTTP for loopback so localnet works without extra flags', () => { + const resolved = resolveConfig(baseConfig({ rpcUrl: 'http://localhost:8000/soroban/rpc' })); + expect(resolved.allowHttp).toBe(true); + }); + + it('refuses plain HTTP for a remote host unless explicitly allowed', () => { + expect(() => + resolveConfig(baseConfig({ rpcUrl: 'http://rpc.example.org' })), + ).toThrow(/Refusing to use plain HTTP/); + + const forced = resolveConfig( + baseConfig({ rpcUrl: 'http://rpc.example.org', allowHttp: true }), + ); + expect(forced.allowHttp).toBe(true); + }); + + it('rejects an invalid contract ID', () => { + expect(() => resolveConfig(baseConfig({ contractId: 'not-a-contract' }))).toThrow( + ValidationError, + ); + expect(() => resolveConfig(baseConfig({ contractId: TEST_PUBLIC_KEY }))).toThrow( + /not a valid Soroban contract ID/, + ); + }); + + it('rejects an invalid public key', () => { + expect(() => resolveConfig(baseConfig({ publicKey: 'GBADKEY' }))).toThrow( + /not a valid Stellar account ID/, + ); + // A contract ID is not a valid source account. + expect(() => resolveConfig(baseConfig({ publicKey: TEST_CONTRACT_ID }))).toThrow( + ValidationError, + ); + }); + + it.each([ + ['a non-numeric fee', { fee: '10.5' }], + ['a zero timeout', { timeoutSeconds: 0 }], + ['a negative poll timeout', { pollTimeoutMs: -1 }], + ['a non-function signer', { signTransaction: 'nope' as never }], + ])('rejects %s', (_label, overrides) => { + expect(() => resolveConfig(baseConfig(overrides))).toThrow(ConfigError); + }); +}); + +describe('networkPreset', () => { + it('exposes the values declared in soroban.toml', () => { + expect(networkPreset('local')).toEqual({ + rpcUrl: 'http://localhost:8000/soroban/rpc', + networkPassphrase: 'Standalone Network ; February 2017', + }); + expect(networkPreset('testnet').networkPassphrase).toBe('Test SDF Network ; September 2015'); + }); + + it('rejects an unknown network name', () => { + expect(() => networkPreset('staging' as never)).toThrow(ConfigError); + }); +}); + +describe('assertSymbol', () => { + it('accepts a valid short symbol', () => { + expect(assertSymbol('USDCSIM')).toBe('USDCSIM'); + }); + + it('rejects symbols longer than the contract allows', () => { + // The contract stores asset codes with `symbol_short!`, capped at 9 chars. + expect(() => assertSymbol('TOOLONGSYMBOL')).toThrow(/at most 9/); + // The general Symbol limit is 32. + expect(() => assertSymbol('A'.repeat(33), 'reason', false)).toThrow(/at most 32/); + }); + + it('rejects symbols with characters Soroban does not permit', () => { + expect(() => assertSymbol('BAD-SYM')).toThrow(/letters, digits and underscores/); + }); +}); + +describe('assertPositiveAmount', () => { + it('requires bigint to avoid silent i128 precision loss', () => { + expect(() => assertPositiveAmount(100 as never)).toThrow(/expected a bigint/); + }); + + it('rejects zero and negative amounts', () => { + expect(() => assertPositiveAmount(0n)).toThrow(/greater than zero/); + expect(() => assertPositiveAmount(-5n)).toThrow(/greater than zero/); + }); + + it('preserves large i128 values exactly', () => { + const large = 170141183460469231731687303715884105727n; + expect(assertPositiveAmount(large)).toBe(large); + }); +}); + +describe('SwapTradeClient construction', () => { + it('exposes the resolved config', () => { + const client = new SwapTradeClient(baseConfig()); + expect(client.config.contractId).toBe(TEST_CONTRACT_ID); + expect(client.config.publicKey).toBe(TEST_PUBLIC_KEY); + }); + + it('supports the static factory', () => { + expect(SwapTradeClient.create(baseConfig())).toBeInstanceOf(SwapTradeClient); + }); + + it('fails fast on invalid configuration rather than at first call', () => { + expect(() => new SwapTradeClient(baseConfig({ contractId: 'nope' }))).toThrow( + ValidationError, + ); + }); +}); diff --git a/packages/swaptrade-sdk/test/helpers.ts b/packages/swaptrade-sdk/test/helpers.ts new file mode 100644 index 0000000..63cc8ef --- /dev/null +++ b/packages/swaptrade-sdk/test/helpers.ts @@ -0,0 +1,127 @@ +/** + * Shared test helpers. + * + * A fake RPC server is injected at the `RpcServerLike` boundary so tests cover + * the SDK's own build/simulate/sign/submit logic without any network access. + */ +import { Account, Keypair, SorobanDataBuilder, nativeToScVal, xdr } from '@stellar/stellar-sdk'; +import { vi } from 'vitest'; +import type { RpcServerLike } from '../src/client.js'; +import { keypairSigner } from '../src/signers.js'; +import { NETWORKS, type SwapTradeConfig } from '../src/types.js'; + +/** A deterministic, syntactically valid contract ID for tests. */ +export const TEST_CONTRACT_ID = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + +/** + * Stable test keypair, derived from a fixed 32-byte seed rather than written out + * as a literal secret. It controls no real funds on any network, and deriving it + * keeps anything that looks like a credential out of the repository. + */ +export const TEST_KEYPAIR = Keypair.fromRawEd25519Seed( + Buffer.alloc(32, 7), +); + +export const TEST_PUBLIC_KEY = TEST_KEYPAIR.publicKey(); + +/** Options for {@link createFakeServer}. */ +export interface FakeServerOptions { + /** Value returned by `simulateTransaction`. */ + simulateResult?: unknown; + /** Value returned by `sendTransaction`. */ + sendResult?: unknown; + /** Sequence of values returned by successive `getTransaction` calls. */ + getTransactionResults?: unknown[]; + /** Force `getAccount` to reject. */ + accountError?: Error; + /** Force `simulateTransaction` to reject. */ + simulateError?: Error; + /** Force `sendTransaction` to reject. */ + sendError?: Error; +} + +/** + * Build a successful simulation response. + * + * This mirrors the *raw* JSON-RPC shape (`results[].xdr` as base64), because + * `assembleTransaction` re-parses the response internally. Using the real shape + * means the client's actual assemble/sign path is exercised rather than stubbed. + */ +export function simulationSuccess(retval?: xdr.ScVal): Record { + const returned = retval ?? xdr.ScVal.scvVoid(); + return { + transactionData: new SorobanDataBuilder().build().toXDR('base64'), + minResourceFee: '12345', + latestLedger: 100, + events: [], + results: [{ xdr: returned.toXDR('base64'), auth: [] }], + // The parsed `result.retval` the client reads for its return value. + result: { retval: returned, auth: [] }, + }; +} + +/** Build a failed simulation response. */ +export function simulationFailure(error: string): Record { + return { error, events: [], latestLedger: 100 }; +} + +/** + * Create a fake RPC server. + * + * `getAccount` returns a real `Account` so `TransactionBuilder` behaves exactly + * as it would in production. + */ +export function createFakeServer(options: FakeServerOptions = {}): RpcServerLike & { + getAccount: ReturnType; + simulateTransaction: ReturnType; + sendTransaction: ReturnType; + getTransaction: ReturnType; +} { + const getTransactionResults = options.getTransactionResults ?? [ + { status: 'SUCCESS', ledger: 101 }, + ]; + let pollIndex = 0; + + return { + getAccount: vi.fn(async (address: string) => { + if (options.accountError) throw options.accountError; + return new Account(address, '1'); + }), + simulateTransaction: vi.fn(async () => { + if (options.simulateError) throw options.simulateError; + return options.simulateResult ?? simulationSuccess(); + }), + sendTransaction: vi.fn(async () => { + if (options.sendError) throw options.sendError; + return options.sendResult ?? { status: 'PENDING', hash: 'a'.repeat(64) }; + }), + getTransaction: vi.fn(async () => { + const result = + getTransactionResults[Math.min(pollIndex, getTransactionResults.length - 1)]; + pollIndex += 1; + return result; + }), + }; +} + +/** A valid base config for the local network, with a working local signer. */ +export function baseConfig(overrides: Partial = {}): SwapTradeConfig { + return { + rpcUrl: NETWORKS.local.rpcUrl, + networkPassphrase: NETWORKS.local.networkPassphrase, + contractId: TEST_CONTRACT_ID, + publicKey: TEST_PUBLIC_KEY, + signTransaction: keypairSigner(TEST_KEYPAIR.secret()), + ...overrides, + }; +} + +/** Encode an `i128` return value for a simulation response. */ +export function i128Return(value: bigint): xdr.ScVal { + return nativeToScVal(value, { type: 'i128' }); +} + +/** Encode a `u64` return value for a simulation response. */ +export function u64Return(value: bigint): xdr.ScVal { + return nativeToScVal(value, { type: 'u64' }); +} diff --git a/packages/swaptrade-sdk/test/scval.test.ts b/packages/swaptrade-sdk/test/scval.test.ts new file mode 100644 index 0000000..693826e --- /dev/null +++ b/packages/swaptrade-sdk/test/scval.test.ts @@ -0,0 +1,201 @@ +/** + * ScVal encoding/decoding. + * + * These assertions round-trip through the real `@stellar/stellar-sdk` codec, so + * they verify the SDK's mapping against the contract's actual wire format rather + * than against a re-implementation of it. + */ +import { nativeToScVal, xdr } from '@stellar/stellar-sdk'; +import { describe, expect, it } from 'vitest'; +import { + ContractCallError, + contractErrorName, + decodeKycStatus, + decodeOrder, + decodeOrderStatus, + decodeOrderType, + decodePortfolio, + fromScVal, + i128ToScVal, + kycStatusToScVal, + optionToScVal, + parseContractErrorCode, + symbolToScVal, + tupleToScVal, + u128ToScVal, + u64ToScVal, + unitEnumToScVal, +} from '../src/index.js'; + +describe('scalar encoding round-trips', () => { + it('encodes symbols', () => { + expect(fromScVal(symbolToScVal('USDCSIM', 'token'))).toBe('USDCSIM'); + }); + + it('preserves the full i128 range', () => { + const max = 170141183460469231731687303715884105727n; + expect(fromScVal(i128ToScVal(max))).toBe(max); + expect(fromScVal(i128ToScVal(-max))).toBe(-max); + }); + + it('preserves large u128 values', () => { + const value = 340282366920938463463374607431768211455n; + expect(fromScVal(u128ToScVal(value))).toBe(value); + }); + + it('encodes u64', () => { + expect(fromScVal(u64ToScVal(1_800_000_000n))).toBe(1_800_000_000n); + }); +}); + +describe('Option encoding', () => { + it('encodes None as void', () => { + expect(optionToScVal(undefined, u64ToScVal).switch().name).toBe('scvVoid'); + expect(optionToScVal(null, u64ToScVal).switch().name).toBe('scvVoid'); + }); + + it('encodes Some as the inner value', () => { + expect(fromScVal(optionToScVal(9n, u64ToScVal))).toBe(9n); + }); + + it('treats 0 as Some(0) rather than None', () => { + // A falsy-but-present value must not collapse to None. + expect(fromScVal(optionToScVal(0n, u64ToScVal))).toBe(0n); + }); +}); + +describe('enum and tuple encoding', () => { + it('encodes a unit enum variant as a single-element vector', () => { + expect(fromScVal(unitEnumToScVal('Verified'))).toEqual(['Verified']); + }); + + it('encodes a token pair tuple', () => { + const pair = tupleToScVal([symbolToScVal('XLM', 'a'), symbolToScVal('USDCSIM', 'b')]); + expect(fromScVal(pair)).toEqual(['XLM', 'USDCSIM']); + }); + + it('encodes a valid KYC status and rejects an invalid one', () => { + expect(fromScVal(kycStatusToScVal('Verified'))).toEqual(['Verified']); + expect(() => kycStatusToScVal('Approved' as never)).toThrow(ContractCallError); + }); +}); + +describe('unit enum decoding', () => { + it('decodes from a variant name', () => { + expect(decodeOrderStatus('Filled')).toBe('Filled'); + expect(decodeOrderType('Limit')).toBe('Limit'); + expect(decodeKycStatus('Rejected')).toBe('Rejected'); + }); + + it('decodes from the single-element vector form', () => { + expect(decodeOrderStatus(['Cancelled'])).toBe('Cancelled'); + }); + + it('decodes from a numeric discriminant', () => { + // KYCStatus assigns Verified = 4 in counter/src/kyc.rs. + expect(decodeKycStatus(4)).toBe('Verified'); + expect(decodeOrderStatus(0)).toBe('Pending'); + }); + + it('rejects an unrecognised variant instead of guessing', () => { + expect(() => decodeOrderStatus('Bogus')).toThrow(ContractCallError); + expect(() => decodeKycStatus(99)).toThrow(/Could not decode/); + }); +}); + +describe('decodeOrder', () => { + const raw = { + order_id: 7n, + owner: 'GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57', + order_type: 'Limit', + token_in: 'XLM', + token_out: 'USDCSIM', + amount_in: 1_000n, + amount_filled: 0n, + limit_price: 1_000_000n, + trigger_price: null, + status: 'Pending', + created_at: 1_700_000_000n, + expires_at: null, + filled_at: null, + interval_secs: null, + remaining_occurrences: null, + next_run: null, + }; + + it('maps snake_case contract fields to camelCase', () => { + const order = decodeOrder(raw); + expect(order.orderId).toBe(7n); + expect(order.tokenIn).toBe('XLM'); + expect(order.tokenOut).toBe('USDCSIM'); + expect(order.amountIn).toBe(1_000n); + expect(order.orderType).toBe('Limit'); + expect(order.status).toBe('Pending'); + }); + + it('represents contract None as undefined, not zero', () => { + // Collapsing None to 0 would make "no expiry" look like "expired at epoch". + const order = decodeOrder(raw); + expect(order.expiresAt).toBeUndefined(); + expect(order.triggerPrice).toBeUndefined(); + expect(order.limitPrice).toBe(1_000_000n); + }); + + it('decodes present optional fields', () => { + const order = decodeOrder({ ...raw, expires_at: 1_800_000_000n }); + expect(order.expiresAt).toBe(1_800_000_000n); + }); + + it('rejects a non-object payload', () => { + expect(() => decodeOrder(null)).toThrow(ContractCallError); + }); +}); + +describe('decodePortfolio', () => { + it('decodes the (u32, i128) tuple returned by get_portfolio', () => { + expect(decodePortfolio([4, 9_999n])).toEqual({ tradeCount: 4, totalVolume: 9_999n }); + }); + + it('round-trips through real XDR', () => { + const encoded = tupleToScVal([ + nativeToScVal(2, { type: 'u32' }), + i128ToScVal(500n), + ]); + expect(decodePortfolio(fromScVal(encoded))).toEqual({ + tradeCount: 2, + totalVolume: 500n, + }); + }); + + it('rejects a malformed tuple', () => { + expect(() => decodePortfolio([1])).toThrow(/tuple/); + expect(() => decodePortfolio('nope')).toThrow(ContractCallError); + }); +}); + +describe('contract error parsing', () => { + it.each([ + ['HostError: Error(Contract, #500)', 500], + ['... Error(Contract, #300) ...', 300], + ['ContractError(104)', 104], + ])('extracts a code from %s', (message, expected) => { + expect(parseContractErrorCode(message)).toBe(expected); + }); + + it('returns undefined for a non-contract error', () => { + expect(parseContractErrorCode('connection refused')).toBeUndefined(); + }); + + it('resolves codes to the names declared in errors.rs', () => { + expect(contractErrorName(500)).toBe('KYCVerificationRequired'); + expect(contractErrorName(301)).toBe('SlippageExceeded'); + expect(contractErrorName(10)).toBe('TradingPaused'); + expect(contractErrorName(4242)).toBeUndefined(); + }); +}); + +describe('void decoding', () => { + it('decodes scvVoid to null', () => { + expect(fromScVal(xdr.ScVal.scvVoid())).toBeNull(); + }); +}); diff --git a/packages/swaptrade-sdk/test/signers.test.ts b/packages/swaptrade-sdk/test/signers.test.ts new file mode 100644 index 0000000..c758b61 --- /dev/null +++ b/packages/swaptrade-sdk/test/signers.test.ts @@ -0,0 +1,146 @@ +/** + * Signer adapters. + * + * The keypair signer produces a real signature over real transaction XDR, and + * the browser adapter is checked against both Freighter response shapes. + */ +import { Keypair, TransactionBuilder } from '@stellar/stellar-sdk'; +import { describe, expect, it, vi } from 'vitest'; +import { + NETWORKS, + SigningError, + ValidationError, + browserWalletSigner, + keypairSigner, +} from '../src/index.js'; +import { TEST_KEYPAIR, baseConfig, createFakeServer } from './helpers.js'; +import { SwapTradeClient } from '../src/index.js'; + +const passphrase = NETWORKS.local.networkPassphrase; + +/** Build a real unsigned transaction envelope to hand to a signer. */ +async function unsignedXdr(): Promise { + const client = new SwapTradeClient(baseConfig(), { server: createFakeServer() }); + const tx = await client.buildTransaction('get_contract_version', []); + return tx.toXDR(); +} + +describe('keypairSigner', () => { + it('rejects a malformed secret key up front', () => { + expect(() => keypairSigner('not-a-secret')).toThrow(ValidationError); + // A public key is not a signing key. + expect(() => keypairSigner(TEST_KEYPAIR.publicKey())).toThrow(/secret seed/); + }); + + it('produces a signature the network passphrase verifies against', async () => { + const signer = keypairSigner(TEST_KEYPAIR.secret()); + const signed = await signer(await unsignedXdr(), { + networkPassphrase: passphrase, + address: TEST_KEYPAIR.publicKey(), + }); + + const tx = TransactionBuilder.fromXDR(signed, passphrase); + expect(tx.signatures).toHaveLength(1); + // Verifying against the transaction hash proves this is a real signature, + // not just an envelope with a signature-shaped blob attached. + const hash = tx.hash(); + expect(TEST_KEYPAIR.verify(hash, tx.signatures[0]!.signature())).toBe(true); + }); + + it('reports unparseable XDR as a SigningError', () => { + const signer = keypairSigner(TEST_KEYPAIR.secret()); + expect(() => + signer('this-is-not-xdr', { networkPassphrase: passphrase, address: '' }), + ).toThrow(SigningError); + }); + + it('signs over the passphrase it is given, so a mismatch invalidates the signature', async () => { + // The passphrase is not carried in the envelope; it feeds the transaction + // hash. Signing under the wrong one therefore succeeds locally and is only + // rejected by the network, which is why the client passes its resolved + // config passphrase rather than letting the signer choose. + const signer = keypairSigner(TEST_KEYPAIR.secret()); + const signed = await signer(await unsignedXdr(), { + networkPassphrase: NETWORKS.testnet.networkPassphrase, + address: TEST_KEYPAIR.publicKey(), + }); + + const wrongNetwork = TransactionBuilder.fromXDR( + signed, + NETWORKS.testnet.networkPassphrase, + ); + expect(TEST_KEYPAIR.verify(wrongNetwork.hash(), wrongNetwork.signatures[0]!.signature())) + .toBe(true); + + const localNetwork = TransactionBuilder.fromXDR(signed, passphrase); + expect(TEST_KEYPAIR.verify(localNetwork.hash(), localNetwork.signatures[0]!.signature())) + .toBe(false); + }); +}); + +describe('browserWalletSigner', () => { + it('accepts the { signedTxXdr } shape returned by current Freighter', async () => { + const wallet = { + signTransaction: vi.fn(async () => ({ signedTxXdr: 'SIGNED_XDR' })), + }; + const signer = browserWalletSigner(wallet); + + await expect( + signer('UNSIGNED', { networkPassphrase: passphrase, address: 'GABC' }), + ).resolves.toBe('SIGNED_XDR'); + expect(wallet.signTransaction).toHaveBeenCalledWith('UNSIGNED', { + networkPassphrase: passphrase, + address: 'GABC', + }); + }); + + it('accepts the bare-string shape returned by older wallets', async () => { + const signer = browserWalletSigner({ + signTransaction: vi.fn(async () => 'SIGNED_XDR'), + }); + await expect( + signer('UNSIGNED', { networkPassphrase: passphrase, address: 'GABC' }), + ).resolves.toBe('SIGNED_XDR'); + }); + + it.each([ + ['an empty string', ''], + ['whitespace only', ' '], + ['an object with no XDR', {} as never], + ])('treats %s as a rejected signature request', async (_label, result) => { + const signer = browserWalletSigner({ signTransaction: vi.fn(async () => result) }); + await expect( + signer('UNSIGNED', { networkPassphrase: passphrase, address: 'GABC' }), + ).rejects.toThrow(/may have been rejected/); + }); + + it('propagates a wallet-level rejection', async () => { + const signer = browserWalletSigner({ + signTransaction: vi.fn(async () => { + throw new Error('User declined access'); + }), + }); + await expect( + signer('UNSIGNED', { networkPassphrase: passphrase, address: 'GABC' }), + ).rejects.toThrow(/User declined access/); + }); +}); + +describe('end-to-end signing through the client', () => { + it('submits an envelope carrying a verifiable signature', async () => { + const server = createFakeServer(); + const client = new SwapTradeClient(baseConfig(), { server }); + + await client.mint('XLM', TEST_KEYPAIR.publicKey(), 1n); + + const submitted = server.sendTransaction.mock.calls[0]![0] as { toXDR(): string }; + const tx = TransactionBuilder.fromXDR(submitted.toXDR(), passphrase); + expect(tx.signatures).toHaveLength(1); + expect( + Keypair.fromPublicKey(TEST_KEYPAIR.publicKey()).verify( + tx.hash(), + tx.signatures[0]!.signature(), + ), + ).toBe(true); + }); +}); diff --git a/packages/swaptrade-sdk/tsconfig.build.json b/packages/swaptrade-sdk/tsconfig.build.json new file mode 100644 index 0000000..099b980 --- /dev/null +++ b/packages/swaptrade-sdk/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["test", "**/*.test.ts"] +} diff --git a/packages/swaptrade-sdk/tsconfig.json b/packages/swaptrade-sdk/tsconfig.json new file mode 100644 index 0000000..4574a51 --- /dev/null +++ b/packages/swaptrade-sdk/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ES2022", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": false, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/swaptrade-sdk/vitest.config.ts b/packages/swaptrade-sdk/vitest.config.ts new file mode 100644 index 0000000..a71174d --- /dev/null +++ b/packages/swaptrade-sdk/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + }, +}); diff --git a/scripts/localnet_deploy.sh b/scripts/localnet_deploy.sh new file mode 100644 index 0000000..4f36136 --- /dev/null +++ b/scripts/localnet_deploy.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# Start a Stellar localnet, build a contract, deploy it, and print the +# environment the demo needs. +# +# Deliberately does NOT write .env.local for you: the file is git-ignored and +# holds a key, so creating it is left as an explicit copy-paste step. +# +# Usage: +# scripts/localnet_deploy.sh # deploy soroban-ping (builds cleanly) +# CONTRACT=counter scripts/localnet_deploy.sh # attempt the counter contract +# +# See docs/LOCALNET.md for the full walkthrough and the current build status of +# each contract. + +set -euo pipefail + +CONTRACT="${CONTRACT:-soroban-ping}" +NETWORK_NAME="${NETWORK_NAME:-local}" +RPC_URL="${RPC_URL:-http://localhost:8000/soroban/rpc}" +NETWORK_PASSPHRASE="${NETWORK_PASSPHRASE:-Standalone Network ; February 2017}" +CONTAINER="${CONTAINER:-swaptrade-localnet}" +IDENTITY="${IDENTITY:-demo}" +WASM_TARGET="wasm32v1-none" + +# Crate name -> wasm file name (cargo replaces dashes with underscores). +WASM_NAME="${CONTRACT//-/_}.wasm" +WASM_PATH="target/${WASM_TARGET}/release/${WASM_NAME}" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m warn:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +# --- 1. Preconditions ------------------------------------------------------ + +command -v docker >/dev/null 2>&1 || die "docker is required but not on PATH." +command -v cargo >/dev/null 2>&1 || die "cargo is required but not on PATH." +command -v stellar >/dev/null 2>&1 || die \ + "The stellar CLI is required. Install it with: + cargo install --locked stellar-cli + or see https://developers.stellar.org/docs/tools/developer-tools/cli/install-cli" + +# soroban-sdk's build script rejects wasm32-unknown-unknown on Rust >= 1.82. +if ! rustup target list --installed | grep -qx "${WASM_TARGET}"; then + log "Adding Rust target ${WASM_TARGET}" + rustup target add "${WASM_TARGET}" +fi + +# --- 2. Localnet ----------------------------------------------------------- + +if [ "$(docker inspect -f '{{.State.Running}}' "${CONTAINER}" 2>/dev/null)" = "true" ]; then + log "Localnet container '${CONTAINER}' is already running" +else + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + log "Starting localnet container '${CONTAINER}'" + docker run -d --name "${CONTAINER}" \ + -p 8000:8000 \ + stellar/quickstart:latest --local --enable-soroban-rpc >/dev/null +fi + +log "Waiting for Soroban RPC on ${RPC_URL}" +for _ in $(seq 1 90); do + if curl -fsS -X POST "${RPC_URL}" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getHealth"}' 2>/dev/null | grep -q healthy; then + log "RPC is healthy" + break + fi + sleep 2 +done + +curl -fsS -X POST "${RPC_URL}" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"getHealth"}' 2>/dev/null | grep -q healthy \ + || die "RPC did not become healthy. Check: docker logs ${CONTAINER}" + +# --- 3. Network and identity -------------------------------------------------- + +log "Registering network '${NETWORK_NAME}' with the CLI" +stellar network add "${NETWORK_NAME}" \ + --rpc-url "${RPC_URL}" \ + --network-passphrase "${NETWORK_PASSPHRASE}" \ + --overwrite >/dev/null 2>&1 || \ +stellar network add "${NETWORK_NAME}" \ + --rpc-url "${RPC_URL}" \ + --network-passphrase "${NETWORK_PASSPHRASE}" >/dev/null 2>&1 || true + +if ! stellar keys address "${IDENTITY}" >/dev/null 2>&1; then + log "Generating identity '${IDENTITY}'" + stellar keys generate "${IDENTITY}" --network "${NETWORK_NAME}" --fund +else + log "Identity '${IDENTITY}' already exists; funding it" + stellar keys fund "${IDENTITY}" --network "${NETWORK_NAME}" >/dev/null 2>&1 || true +fi + +PUBLIC_KEY="$(stellar keys address "${IDENTITY}")" +log "Using account ${PUBLIC_KEY}" + +# --- 4. Build --------------------------------------------------------------- + +log "Building ${CONTRACT} for ${WASM_TARGET}" +if ! cargo build --release --target "${WASM_TARGET}" -p "${CONTRACT}"; then + die "Build failed for '${CONTRACT}'. +The 'counter' crate does not currently compile on a clean checkout (pre-existing, +unrelated to the SDK). Run without CONTRACT set to deploy 'soroban-ping' instead, +and see docs/LOCALNET.md for details." +fi + +[ -f "${WASM_PATH}" ] || die "Expected wasm at ${WASM_PATH} but it was not produced." + +# --- 5. Deploy -------------------------------------------------------------- + +log "Deploying ${WASM_NAME}" +CONTRACT_ID="$(stellar contract deploy \ + --wasm "${WASM_PATH}" \ + --source "${IDENTITY}" \ + --network "${NETWORK_NAME}")" + +[ -n "${CONTRACT_ID}" ] || die "Deploy did not return a contract ID." + +# --- 6. Report -------------------------------------------------------------- + +cat < simulate -> assemble -> sign -> submit -> poll pipeline works + * against an actual network, not just against the fake RPC server used by the + * unit tests. + * + * ## Why a secret key is acceptable here and not in the browser + * + * This runs in Node. The key stays in the process and its environment; it is + * never written into an asset served to anyone. The example DApp deliberately + * has no equivalent path, because Vite inlines `VITE_`-prefixed values into the + * public bundle — see examples/swap-demo/src/signer.ts. + * + * Usage: + * # Ephemeral: generate and fund a throwaway localnet key, use it, discard it. + * node --experimental-strip-types scripts/verify_localnet.ts \ + * --contract --ephemeral + * + * # Or supply an existing localnet identity. + * node --experimental-strip-types scripts/verify_localnet.ts \ + * --contract --secret + * + * Values come from the command line or the environment; nothing is hardcoded. + */ +import { SwapTradeClient, keypairSigner, NETWORKS } from '../packages/swaptrade-sdk/dist/index.js'; + +/** Read `--flag value` from argv, falling back to an environment variable. */ +function arg(flag: string, envVar: string): string | undefined { + const index = process.argv.indexOf(`--${flag}`); + if (index !== -1 && process.argv[index + 1]) return process.argv[index + 1]; + return process.env[envVar]; +} + +/** Whether a boolean `--flag` is present. */ +function flag(name: string): boolean { + return process.argv.includes(`--${name}`); +} + +const contractId = arg('contract', 'SWAPTRADE_CONTRACT_ID'); +const rpcUrl = arg('rpc', 'SOROBAN_RPC_URL') ?? NETWORKS.local.rpcUrl; +const networkPassphrase = + arg('passphrase', 'SOROBAN_NETWORK_PASSPHRASE') ?? NETWORKS.local.networkPassphrase; + +const { Keypair } = await import('@stellar/stellar-sdk'); + +/** + * Fund an account with friendbot. + * + * The quickstart localnet exposes friendbot on the same host as RPC, so a + * throwaway identity can be created and funded without the CLI or a stored key. + */ +async function fundWithFriendbot(publicKey: string): Promise { + const friendbot = new URL(rpcUrl); + friendbot.pathname = '/friendbot'; + friendbot.search = `?addr=${publicKey}`; + + const response = await fetch(friendbot); + if (!response.ok) { + throw new Error( + `Friendbot could not fund ${publicKey} (HTTP ${response.status}). ` + + 'Is this a localnet with friendbot enabled? Pass --secret to use an existing identity instead.', + ); + } +} + +/** + * Resolve the signing identity. + * + * `--ephemeral` generates a key that exists only for this process, which keeps + * the common case free of any stored or pasted credential. + */ +async function resolveIdentity(): Promise<{ secret: string; publicKey: string; source: string }> { + const supplied = arg('secret', 'SWAPTRADE_SECRET_KEY'); + + if (supplied) { + return { + secret: supplied, + publicKey: Keypair.fromSecret(supplied).publicKey(), + source: 'supplied identity', + }; + } + + if (!flag('ephemeral')) { + throw new Error('no identity'); + } + + const keypair = Keypair.random(); + console.log(`Generating an ephemeral identity: ${keypair.publicKey()}`); + await fundWithFriendbot(keypair.publicKey()); + console.log('Funded via friendbot. It is discarded when this process exits.\n'); + + return { + secret: keypair.secret(), + publicKey: keypair.publicKey(), + source: 'ephemeral (generated, funded, discarded)', + }; +} + +if (!contractId) { + console.error( + 'Usage: node --experimental-strip-types scripts/verify_localnet.ts \\\n' + + ' --contract [--ephemeral | --secret ]\n\n' + + 'Or set SWAPTRADE_CONTRACT_ID and SWAPTRADE_SECRET_KEY.', + ); + process.exit(2); +} + +let identity: { secret: string; publicKey: string; source: string }; +try { + identity = await resolveIdentity(); +} catch (error) { + if (error instanceof Error && error.message === 'no identity') { + console.error( + 'No signing identity. Either:\n' + + ' --ephemeral generate and fund a throwaway localnet key, or\n' + + ' --secret use an existing identity (or SWAPTRADE_SECRET_KEY)\n\n' + + 'A secret is safe here because this is a Node process, not a browser bundle.', + ); + process.exit(2); + } + throw error; +} + +const client = new SwapTradeClient({ + rpcUrl, + networkPassphrase, + contractId, + publicKey: identity.publicKey, + signTransaction: keypairSigner(identity.secret), +}); + +console.log(`RPC: ${rpcUrl}`); +console.log(`Contract: ${contractId}`); +console.log(`Account: ${identity.publicKey}`); +console.log(`Identity: ${identity.source}\n`); + +// 1. Read-only path: build -> simulate -> decode, no submission. +const simulated = await client.simulate('ping', []); +console.log(`simulate('ping') -> ${JSON.stringify(simulated.returnValue)}`); +if (simulated.returnValue !== 'pong') { + throw new Error(`Expected "pong" from simulation, got ${JSON.stringify(simulated.returnValue)}`); +} + +// 2. Full write path: build -> simulate -> assemble -> sign -> submit -> poll. +// `ping` mutates nothing, but submitting it exercises every stage of the +// pipeline against a real network, which is the point of this check. +const submitted = await client.invoke('ping', []); +console.log(`invoke('ping') -> ${JSON.stringify(submitted.returnValue)}`); +console.log(` status: ${submitted.status}`); +console.log(` hash: ${submitted.hash}`); +console.log(` ledger: ${submitted.ledger}`); + +if (submitted.status !== 'SUCCESS') { + throw new Error(`Expected SUCCESS, got ${submitted.status}`); +} +if (submitted.returnValue !== 'pong') { + throw new Error(`Expected "pong" from invocation, got ${JSON.stringify(submitted.returnValue)}`); +} + +console.log('\nOK: simulate and invoke both round-tripped through localnet.');