Skip to content

feat(sdk): add TypeScript SDK and example DApp for the SwapTrade contracts - #264

Open
Cypher-Aura-19 wants to merge 1 commit into
StelTade:mainfrom
Cypher-Aura-19:feat/254-developer-sdk-example
Open

feat(sdk): add TypeScript SDK and example DApp for the SwapTrade contracts#264
Cypher-Aura-19 wants to merge 1 commit into
StelTade:mainfrom
Cypher-Aura-19:feat/254-developer-sdk-example

Conversation

@Cypher-Aura-19

Copy link
Copy Markdown

Summary

Adds a typed TypeScript SDK for the SwapTrade Soroban contracts (packages/swaptrade-sdk) and a small React demo that drives it (examples/swap-demo). Before this, calling place_limit_order from an application meant reading counter/src/lib.rs, working out the positional argument order, and hand-encoding each value into an ScVal — including the cases where the naive mapping is wrong. That work is now done once, in a tested layer, instead of once per integrator.

No contract was modified. Where the issue's vocabulary had no counterpart in the contracts, I mapped onto the real primitives and documented the mismatch rather than adding contract functions to make the example read neatly.

Closes #254

Type of change

  • Contract change (Rust / Soroban)
  • SDK change (packages/swaptrade-sdk)
  • Example DApp change (examples/swap-demo)
  • CI / tooling
  • Documentation

What changed

SDKpackages/swaptrade-sdk

  • src/client.tsSwapTradeClient: 23 methods mapped to real entry points, plus public buildTransaction / simulate / invoke so an unwrapped method is reachable without waiting for a wrapper. Read-only calls simulate (no fee, no signer); state-changing calls simulate → sign → submit → poll.
  • src/scval.ts — encoding and decoding for the places Rust and JS do not line up: Option::None is ScVal::Void and decodes to undefined rather than 0, fieldless enums are one-element vectors (order matters — Soroban also encodes by index), tuples are vectors, and symbol_short! is capped at 9 characters where general Symbol allows 32.
  • src/errors.ts — 9 typed error classes with a code discriminator, so callers branch on a value instead of matching message strings. ContractCallError resolves a numeric code against the catalogue in counter/src/errors.rs, turning Error(Contract, #500) into KYCVerificationRequired (#500).
  • src/config.tsrpcUrl, networkPassphrase, contractId and publicKey are required with no defaults. A default 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 refused except on loopback.
  • i128 / u128 / u64 are bigint in and out; a number is rejected rather than silently truncated above 2^53.

Demoexamples/swap-demo

  • Layering is one-directional and enforced by structure: components.tsx is presentational, workflow.ts is the entire SDK boundary, config.ts is the only reader of import.meta.env, signer.ts is the only signing decision.
  • src/signer.ts imports browserWalletSigner only. keypairSigner is never imported into anything Vite bundles, so the demo has no secret-key code path at all — setting a VITE_*_SECRET_KEY has no effect because nothing reads it. Vite inlines every VITE_-prefixed value into the shipped JavaScript, so a key supplied that way is published, not configured.
  • Without a wallet the app degrades to read-only: the four workflow buttons disable, Refresh state still works (reads are simulate-only), and the notice tells the user to install an extension. A missing wallet is never reported as a prompt for a key.

Tooling

  • scripts/localnet_deploy.sh — reduces the 12-step localnet setup to one command.
  • scripts/verify_localnet.ts — drives the real SDK against a live network. Uses keypairSigner, which is safe here because a Node process is not a public asset, and defaults to --ephemeral: an identity generated, funded by friendbot, and discarded.
  • .github/workflows/sdk.yml — additive and path-filtered to packages/** and examples/**. The three dormant Rust workflows are untouched; re-enabling them is a separate decision.

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 points this relies on: counter/src/lib.rsinitialize, mint, balance_of, get_portfolio, place_limit_order, get_order, get_user_orders, execute_due_orders, cancel_order, swap, safe_swap, set_max_slippage_bps, set_price, get_current_price, kyc_submit, kyc_is_verified, kyc_get_status, kyc_update_status, kyc_add_operator, pause_trading, resume_trading, get_user_tier, get_contract_version.

Validation

Real output, run on Windows 11 with Node 22:

$ npm run typecheck
> @swaptrade/sdk@0.1.0 typecheck
> tsc -p tsconfig.json --noEmit
> @swaptrade/swap-demo@0.1.0 typecheck
> tsc --noEmit
(exit 0)

$ npm run test
 Test Files  4 passed (4)
      Tests  95 passed (95)      # @swaptrade/sdk
 Test Files  2 passed (2)
      Tests  26 passed (26)      # @swaptrade/swap-demo

$ npm run build --workspace @swaptrade/sdk
(exit 0)

$ npm run build --workspace @swaptrade/swap-demo
dist/assets/index-C4NYS6jw.css      1.82 kB │ gzip:   0.80 kB
dist/assets/index-BkNYO9by.js   1,191.58 kB │ gzip: 325.65 kB
✓ built in 1.52s

$ npm run test:e2e --workspace @swaptrade/swap-demo
Running 8 tests using 4 workers
  8 passed (25.4s)

$ npm audit
found 0 vulnerabilities

Secret scan against the freshly built bundle, the same three greps CI runs:

PASS: no secret-key-shaped strings in the bundle.
PASS: no secret-shaped VITE_ variables in the bundle.
PASS: no source file reads a secret-shaped VITE_ variable.

Localnet

  • Verified against localnet (scripts/verify_localnet.ts)

The full SDK pipeline ran against a real network and the resulting transaction was independently confirmed:

Network stellar/quickstart:latest, 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, applicationOrder: 1

That is the meaningful result: a fake RPC server cannot catch a malformed resource footprint, a wrong passphrase, or a signature over the wrong hash. Those only fail against a real node, and they did not fail here.

Not verified

Stating the gaps rather than letting the section above read as a stronger claim than it is.

  1. counter does not compile on a clean checkout. cargo check --workspace reports 152 pre-existing errors at 1b76016 with an empty git status — unrelated to this change, and this PR touches no .rs or Cargo.toml file. The consequence is concrete: the localnet walkthrough was executed against soroban-ping, the one workspace member that builds. Every counter-specific method is verified against the contract source and by unit tests, not on-chain.
  2. perform_swap is a stub. counter/src/swap.rs runs its safety checks and 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.
  3. No create_swap / fund_swap / accept_swap exists. The workflow is mapped onto place_limit_order, mint and execute_due_orders. The demo's step table in examples/swap-demo/README.md documents the mapping.
  4. cargo fmt --check fails pre-existing, for two reasons unrelated to this PR: nft is declared at both nft.rs and nft/mod.rs, and trading_comprehensive_test.rs:57 has an unknown positive prefix.
  5. Localnet only. Nothing was exercised against testnet or a public network, and nothing was benchmarked.

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)

129 tests total — 121 unit with no network access, 8 in a real browser. Mocks sit at boundaries, not in the middle, so the real code runs:

Seam What stays real above it
RpcServerLike (SDK tests) encoding, transaction building, simulation handling, signing, submission, polling
the client object (component tests) the workflow mapping and the hook's state machine
globalThis.freighterApi (browser tests) the demo's signer detection and the SDK's wallet adapter

Tests 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 catch an argument in the wrong position. Signature tests verify cryptographically: they parse the signed envelope, recompute the transaction hash, and check the signature against the public key.

The browser mock wallet 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.

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

No credential-shaped literal exists anywhere in the diff. Tests that need a keypair derive one from a fixed seed (Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7))), and the Playwright config derives only the public half.

Notes for reviewers

Two modified files, both purely additive. .gitignore gains Node/Playwright entries plus scoped ! negations re-including the JSON files the workspace needs to install — the repo's existing *.json rule is deliberately broad for generated Soroban artifacts, so a new workspace JSON file needs a negation added. README.md gains one pointer section and a 4-command quickstart. Everything else is new.

On the absent secret-key path. It would have been less code to read a key from a VITE_ variable on localnet, and I had that working before removing it. The reason it is gone: Vite inlines those values into the bundle, so the pattern publishes a key rather than configuring one — and an example that demonstrates a pattern teaches it. Making the path absent rather than discouraged is what makes the guarantee checkable, which is why CI greps the demo's source for anything reading such a variable, not just the bundle. That third grep matters because the first two pass by accident whenever the variable happens to be unset in CI.

Where docs go beyond the code. docs/LOCALNET.md has a "What was actually verified" section separating observed results from wired-but-unexecuted behaviour, and docs/IMPACT.md ends with "What this does not claim". Every number in IMPACT.md is a count of something in this repository; there are no adoption or performance figures, because none were measured.

Reviewable independently: the SDK stands alone and is useful without the demo. If you would rather land it in two passes, the demo, its tests and its docs are separable.

…racts

Adds a typed client for the Soroban contracts and a small React demo that
drives it, so integrators no longer have to hand-encode ScVals and rediscover
the build -> simulate -> assemble -> sign -> submit -> poll sequence.

packages/swaptrade-sdk
  - SwapTradeClient with one method per contract entry point, plus public
    buildTransaction/simulate/invoke so an unwrapped method is still reachable.
  - ScVal encoding and decoding for the cases where a naive Rust-to-JS mapping
    is wrong: Option::None is ScVal::Void and decodes to undefined rather than
    0, fieldless enums are one-element vectors, tuples are vectors, and
    symbol_short! is capped at 9 characters.
  - i128/u128/u64 are bigint in and out; a number is rejected rather than
    silently truncated above 2^53.
  - Typed errors with a code discriminator, resolving contract error numbers
    against the catalogue in counter/src/errors.rs.
  - rpcUrl, networkPassphrase, contractId and publicKey are required with no
    defaults, because a default passphrase means signing against the wrong
    chain. Plain HTTP is refused except on loopback.

examples/swap-demo
  - One screen mapping the workflow onto the primitives the contract actually
    exposes. Layering is one-directional: no component imports the SDK,
    builds a transaction or encodes an ScVal.
  - Browser signing goes through the SDK's SignTransaction callback to an
    injected wallet. src/signer.ts imports browserWalletSigner only, so no
    code path can read a signing key: Vite inlines every VITE_-prefixed value
    into the bundle, which would publish a key rather than configure one.
    There is no key input field, and no VITE_ secret variable.
  - Without a wallet the app degrades to read-only instead of asking for a key.

Verification and CI
  - 121 unit tests with no network access, mocked at boundaries: a fake RPC
    server at the RpcServerLike seam, a fake client at the SDK boundary, and a
    keyless wallet at globalThis.freighterApi. Tests decode the built XDR to
    assert on the exact method name and argument list.
  - 8 Playwright smoke tests run the production build in real Chromium against
    an unreachable RPC port, so they stay self-contained. One fetches the
    served JavaScript and asserts no secret-shaped string is in the artifact.
  - sdk.yml is additive and path-filtered to packages/ and examples/; the
    dormant Rust workflows are untouched. Its secret scan checks the bundle for
    a secret-key shape, the bundle for a secret-shaped VITE_ name, and the
    demo source for anything reading one.
  - scripts/localnet_deploy.sh reduces the 12-step localnet setup to one
    command; scripts/verify_localnet.ts exercises both SDK paths against a live
    network. It uses keypairSigner, which is safe in Node because the process
    is not a public asset, and defaults to --ephemeral: a generated identity,
    funded by friendbot and discarded.

No contract was modified. Two limitations are documented rather than worked
around: counter does not compile on a clean checkout at 1b76016 (152
pre-existing errors), so the on-chain verification in docs/LOCALNET.md ran
against soroban-ping, the one workspace member that builds; and there is no
create_swap/fund_swap/accept_swap trio, so the demo maps onto
place_limit_order, mint and execute_due_orders.

Closes StelTade#254
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Developer SDK & Examples (Stellar SDK + Soroban integration) Labels: feature, bounty, soroban, sdk, examples

1 participant