From 685c04ee53e2b61ffc5e7b36fb1cea33c642d8e4 Mon Sep 17 00:00:00 2001 From: Lawal Abubakar Babatunde Date: Sun, 26 Jul 2026 12:27:56 +0100 Subject: [PATCH 1/2] docs: prepare SDK delivery documentation and examples --- .gitbook.yaml | 5 + .github/workflows/ci.yml | 6 +- .github/workflows/publish.yml | 3 +- README.md | 11 +- docs/README.md | 35 ++++++ docs/SUMMARY.md | 17 +++ docs/api-reference.md | 94 +++++++++++++++ docs/backend-indexing.md | 65 ++++++++++ docs/configuration.md | 93 +++++++++++++++ docs/delivery-checklist.md | 72 +++++++++++ docs/examples.md | 25 ++++ docs/flows.md | 90 ++++++++++++++ docs/getting-started.md | 17 ++- docs/gitbook-publishing.md | 45 +++++++ docs/multichain-architecture.md | 13 ++ docs/releasing.md | 69 +++++++++++ docs/router-paymaster.md | 35 ++++++ docs/transactions.md | 80 +++++++++++++ docs/troubleshooting-and-versioning.md | 72 +++++++++++ examples/README.md | 53 +++++++++ examples/check-paymaster.ts | 12 ++ examples/config.ts | 86 ++++++++++++++ examples/create-router-flow.ts | 34 ++++++ examples/create-router-lockup.ts | 41 +++++++ examples/read-flow.ts | 31 +++++ examples/read-stream-nft.ts | 15 +++ examples/tsconfig.json | 12 ++ examples/wallet-sign-and-submit.ts | 32 +++++ examples/withdraw-router.ts | 28 +++++ package.json | 16 ++- pnpm-lock.yaml | 35 ++++-- scripts/check-doc-links.mjs | 89 ++++++++++++++ scripts/verify-packed-package.mjs | 158 +++++++++++++++++++++++++ 33 files changed, 1468 insertions(+), 21 deletions(-) create mode 100644 .gitbook.yaml create mode 100644 docs/README.md create mode 100644 docs/SUMMARY.md create mode 100644 docs/api-reference.md create mode 100644 docs/backend-indexing.md create mode 100644 docs/configuration.md create mode 100644 docs/delivery-checklist.md create mode 100644 docs/examples.md create mode 100644 docs/flows.md create mode 100644 docs/gitbook-publishing.md create mode 100644 docs/releasing.md create mode 100644 docs/transactions.md create mode 100644 docs/troubleshooting-and-versioning.md create mode 100644 examples/README.md create mode 100644 examples/check-paymaster.ts create mode 100644 examples/config.ts create mode 100644 examples/create-router-flow.ts create mode 100644 examples/create-router-lockup.ts create mode 100644 examples/read-flow.ts create mode 100644 examples/read-stream-nft.ts create mode 100644 examples/tsconfig.json create mode 100644 examples/wallet-sign-and-submit.ts create mode 100644 examples/withdraw-router.ts create mode 100644 scripts/check-doc-links.mjs create mode 100644 scripts/verify-packed-package.mjs diff --git a/.gitbook.yaml b/.gitbook.yaml new file mode 100644 index 0000000..23dad3a --- /dev/null +++ b/.gitbook.yaml @@ -0,0 +1,5 @@ +root: ./docs/ + +structure: + readme: README.md + summary: SUMMARY.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 701cd78..e72cea9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,5 @@ jobs: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm test - - run: pnpm build - - run: mkdir -p artifacts - - run: pnpm pack --pack-destination ./artifacts + - run: pnpm verify + - run: pnpm package:verify diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6130de4..242ca62 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,8 +25,7 @@ jobs: - name: Install trusted-publishing-capable npm run: npm install --global "npm@^11.15.0" - run: pnpm install --frozen-lockfile - - run: pnpm test - - run: pnpm build + - run: pnpm package:verify - name: Verify release tag run: >- node --input-type=module -e diff --git a/README.md b/README.md index e1d793b..1d5e2f8 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,21 @@ adapter can be added without changing application code. > The `0.x` API is under active development. -Detailed guides live in [`docs/`](./docs): +Detailed, GitBook-ready guides live in [`docs/`](./docs): +- [Documentation home](./docs/README.md) - [Getting started](./docs/getting-started.md) +- [Configuration](./docs/configuration.md) +- [Flow streams](./docs/flows.md) +- [Transactions and authorization](./docs/transactions.md) +- [Example implementations](./docs/examples.md) +- [Backend indexing](./docs/backend-indexing.md) - [Multichain architecture](./docs/multichain-architecture.md) - [Generated binding provenance](./docs/generated-bindings.md) - [Router, Stream NFT, and Paymaster development](./docs/router-paymaster.md) +- [API reference](./docs/api-reference.md) +- [Troubleshooting and versioning](./docs/troubleshooting-and-versioning.md) +- [SDK delivery checklist](./docs/delivery-checklist.md) ## Install diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..90752cf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# Fundable SDK + +`@fundable/sdk` is the TypeScript integration layer for Fundable Protocol. +It gives applications a typed, high-level API for token streams while keeping +chain-specific transaction handling inside adapters. + +Stellar is the first production adapter. The shared model is intentionally +chain-neutral so an EVM adapter can be added without redesigning application +code. + +## What you can build + +- create and manage continuous Flow streams; +- create NFT-backed Flow and Lockup streams through the Router; +- read and transfer Stream NFTs; +- prepare bounded Paymaster forwarding transactions; +- use exact `bigint` token amounts across browser and server applications. + +## Start here + +1. Follow [Getting started](getting-started.md) to install and configure the + SDK. +2. Review [configuration](configuration.md) and + [transaction signing](transactions.md) before enabling writes. +3. Run the [example implementations](examples.md) against your deployment. +4. Read [Multichain architecture](multichain-architecture.md) to understand + the stable core and chain-adapter boundary. +5. Use [Router, Stream NFT, and Paymaster](router-paymaster.md) for the + protocol composition contracts. +6. Review [Generated bindings](generated-bindings.md) when validating contract + and SDK release provenance. + +The package is currently a prerelease. Review the +[delivery checklist](delivery-checklist.md) for the remaining work required +before the SDK is promoted to a stable release. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 0000000..13d0f48 --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,17 @@ +# Table of contents + +- [Fundable SDK](README.md) +- [Getting started](getting-started.md) +- [Configuration](configuration.md) +- [Flow streams](flows.md) +- [Transactions and authorization](transactions.md) +- [Example implementations](examples.md) +- [Backend indexing](backend-indexing.md) +- [Multichain architecture](multichain-architecture.md) +- [Router, Stream NFT, and Paymaster](router-paymaster.md) +- [API reference](api-reference.md) +- [Generated Stellar bindings](generated-bindings.md) +- [Troubleshooting and versioning](troubleshooting-and-versioning.md) +- [Publishing with GitBook](gitbook-publishing.md) +- [Release process](releasing.md) +- [Delivery checklist](delivery-checklist.md) diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..10e3782 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,94 @@ +# API reference + +The package exposes three import boundaries: + +```ts +import { createFundableClient } from "@fundable/sdk"; +import { parseUnits, formatUnits, FundableError } from "@fundable/sdk/core"; +import type { StellarFundableClientConfig } from "@fundable/sdk/stellar"; +``` + +## Top-level client + +`createFundableClient(config)` returns `StellarFundableClient` for +`chain: "stellar"`. Its capability groups are: + +| Property | Availability | +| --- | --- | +| `flows` | Always present; `contracts.flow` is required. | +| `router` | Present when `contracts.router` is configured. | +| `streamNft` | Present when `contracts.streamNft` is configured. | +| `paymaster` | Present when `contracts.paymaster` is configured. | + +## Flow client + +| Method | Result | +| --- | --- | +| `create` | Assembled transaction returning stream ID. | +| `createAndDeposit` | Assembled transaction returning stream ID. | +| `deposit`, `withdraw`, `pause`, `restart`, `adjustRate`, `refund`, `void` | Assembled state-changing transaction. | +| `withdrawMax`, `refundMax` | Assembled transaction returning the affected amount. | +| `getStream` | Normalized `FlowRecord`. | +| `getStatus` | Normalized `FlowStatus`. | +| Amount and debt getters | `bigint`. | + +## Router client + +| Method | Result | +| --- | --- | +| `createFlow` | Assembled transaction returning Stream NFT token ID. | +| `createLockup` | Assembled transaction returning Stream NFT token ID. | +| `withdraw` | Assembled transaction for an amount and token ID. | +| `withdrawMax` | Assembled transaction returning the withdrawn amount. | + +## Stream NFT client + +| Method | Result | +| --- | --- | +| `ownerOf` | Owner Stellar address. | +| `balanceOf` | Number of Stream NFTs owned as `bigint`. | +| `getStreamData` | `{ tokenId, streamId, streamKind }`. | +| `transfer` | Assembled transfer transaction. | + +## Paymaster client + +| Method | Result | +| --- | --- | +| `isFeeTokenAllowed` | `boolean`. | +| `forward` | Assembled transaction returning the target result. | + +`forward` requires the user, fee token and bounded fee amounts, expiration +ledger, fee recipient, target contract, function name, and raw Soroban +arguments. It is an advanced API: construct arguments from a reviewed target +contract specification and never forward an arbitrary function supplied by an +untrusted user. + +## Core values + +- `parseUnits(value, decimals)` converts a decimal string to exact `bigint` + base units. +- `formatUnits(value, decimals)` formats base units without floating-point + arithmetic. +- `toUnixSeconds(value, label?)` normalizes a `Date` or non-negative `bigint`. +- `CHAIN_FAMILIES`, `FLOW_STATUSES`, and `STREAM_KINDS` provide stable string + constants. + +## Errors + +SDK validation and adapter failures use `FundableError`: + +```ts +try { + await fundable.flows.getStream("42"); +} catch (error) { + if (error instanceof FundableError) { + console.error(error.code, error.chain, error.message); + } +} +``` + +The stable error codes are `INVALID_ARGUMENT`, `INVALID_CONFIGURATION`, +`UNSUPPORTED_CHAIN`, `UNSUPPORTED_CAPABILITY`, `SIMULATION_FAILED`, +`TRANSACTION_FAILED`, and `RPC_UNAVAILABLE`. The underlying failure may be +available as `error.cause`; do not display raw provider responses to end users +without filtering sensitive data. diff --git a/docs/backend-indexing.md b/docs/backend-indexing.md new file mode 100644 index 0000000..d5bc8ee --- /dev/null +++ b/docs/backend-indexing.md @@ -0,0 +1,65 @@ +# Backend indexing and transaction tracking + +The SDK does not report user activity to Fundable's backend. That is deliberate: +a public integration library should not require a central API call for an +on-chain transaction to work, and clients can bypass the library and call the +contracts directly. + +To track every interaction with Fundable contracts, index the chain rather +than relying on SDK callbacks. + +## Recommended architecture + +```text +Application using SDK ──signs and submits──> Fundable contracts + │ + │ contract events and ledger data + ▼ + Fundable chain indexer + │ + ▼ + normalized backend database +``` + +The production client and third-party applications may submit transaction +hashes to an ingestion endpoint for faster user feedback, but that endpoint is +only a hint. The indexer remains the source of completeness. + +## Indexer responsibilities + +1. Maintain the approved contract deployment registry for each network. +2. Ingest ledgers and events for Flow, Lockup, Router, Stream NFT, Paymaster, + and Distributor contracts. +3. Decode events using the contract release that was active for that address. +4. Upsert normalized streams, ownership, deposits, withdrawals, cancellations, + and sponsored calls using an idempotent event key. +5. Record transaction hash, ledger sequence, event position, network, contract + ID, and contract release as provenance. +6. Advance a durable checkpoint only after all events in a ledger are stored. +7. Reconcile submitted transaction hints against indexed on-chain results. +8. Alert on decode failures, checkpoint lag, and unknown contract addresses. + +An idempotency key should include at least network, ledger sequence, +transaction position, and event position. Never identify an event only by a +stream ID, because multiple operations can affect the same stream. + +## SDK responsibility + +The SDK should: + +- return the native assembled transaction and decoded result; +- expose the final transaction hash after submission; +- keep public input and output types stable across adapters; +- avoid hidden telemetry or a hard dependency on Fundable's backend. + +An optional future convenience method may notify a Fundable API after +submission, but it cannot provide complete accounting. Direct contract calls, +failed notifications, and alternative SDKs would otherwise create gaps. + +## Database boundary + +Store chain facts separately from application metadata. On-chain amounts and +IDs should be losslessly stored as integer strings or database numeric types, +not JavaScript floating-point numbers. Associate off-chain users or projects +through a separate mapping so re-indexing chain state does not overwrite +product-specific data. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..a6e6447 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,93 @@ +# Configuration + +Create one client for each Stellar network and contract deployment your +application uses. + +```ts +import { createFundableClient } from "@fundable/sdk"; + +const fundable = createFundableClient({ + chain: "stellar", + network: "testnet", + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contracts: { + flow: process.env.FUNDABLE_FLOW_CONTRACT!, + router: process.env.FUNDABLE_ROUTER_CONTRACT, + streamNft: process.env.FUNDABLE_STREAM_NFT_CONTRACT, + paymaster: process.env.FUNDABLE_PAYMASTER_CONTRACT, + }, + publicKey: connectedAccount, + signTransaction: wallet.signTransaction, + signAuthEntry: wallet.signAuthEntry, +}); +``` + +## Required values + +| Field | Purpose | +| --- | --- | +| `chain` | Must be `"stellar"` in the current release. | +| `network` | `"public"`, `"testnet"`, or `"custom"`. | +| `rpcUrl` | Soroban JSON-RPC endpoint used for reads and simulation. | +| `networkPassphrase` | Passphrase of the network served by `rpcUrl`. | +| `contracts.flow` | Deployed Flow engine contract ID. | + +The SDK does not infer a passphrase or contract deployment from the network +label. Keep all three values in the same environment configuration to prevent +cross-network signing errors. + +## Optional capabilities + +`router`, `streamNft`, and `paymaster` are only present on the client when the +matching contract ID is configured: + +```ts +if (!fundable.router) { + throw new Error("Router is not configured for this deployment"); +} +``` + +This makes a missing deployment explicit instead of sending a transaction to +an invented or stale address. + +## Signing callbacks + +`publicKey`, `signTransaction`, and `signAuthEntry` are optional for read-only +clients. Supply them for write workflows. The callback shapes come from +`@stellar/stellar-sdk/contract` and can be backed by a browser wallet, a mobile +wallet bridge, or a controlled server signer. + +Never put a secret seed in browser source, environment variables exposed to a +frontend build, documentation examples, or SDK configuration committed to +Git. + +## Server and local RPC + +HTTPS is required by default. Set `allowHttp: true` only for a trusted local +development RPC endpoint. Custom request headers can be supplied with +`headers`, for example when an RPC provider requires an API key. Keep those +headers server-side when they contain credentials. + +## Contract deployment registry + +Production applications should maintain a reviewed registry keyed by network, +release, and chain: + +```ts +const deployments = { + stellar: { + testnet: { + release: "contracts-v0.1.0-alpha.2", + flow: "C...", + router: "C...", + streamNft: "C...", + paymaster: "C...", + }, + }, +} as const; +``` + +Do not accept contract IDs directly from an untrusted URL or user input. A +malicious replacement address would cause the SDK to simulate and sign calls +to a different contract. diff --git a/docs/delivery-checklist.md b/docs/delivery-checklist.md new file mode 100644 index 0000000..f48534c --- /dev/null +++ b/docs/delivery-checklist.md @@ -0,0 +1,72 @@ +# SDK delivery checklist + +The SDK deliverable is complete when the package is published, comprehensive +GitBook documentation is publicly available, and runnable example +implementations have been verified against the published package. + +This page is the release source of truth. A checked item must have a repository +artifact, an automated check, or a public URL that proves completion. + +## 1. Package readiness + +- [x] Public `Fundable-Protocol/fundable-sdk` repository exists. +- [x] `@fundable/sdk` is published to npm as a public prerelease. +- [x] Stellar Flow, Router, Stream NFT, and Paymaster clients are exported. +- [x] Shared domain types preserve the future EVM adapter boundary. +- [x] `stellar_client` is migrated as the first production consumer. +- [x] Package exports are verified from a clean consumer project. +- [ ] Stable version is selected and the package is published under npm's + `latest` tag. +- [ ] Git tag and GitHub release match the published stable package version. + +## 2. Documentation readiness + +- [x] GitBook reads documentation from `docs/` through `.gitbook.yaml`. +- [x] A curated `SUMMARY.md` defines the documentation navigation. +- [x] Installation and configuration cover browser and server applications. +- [x] Flow operations have complete read and write examples. +- [x] Router Flow and Lockup creation are documented. +- [x] Stream NFT reads and transfers are documented. +- [x] Paymaster security boundaries and forwarding are documented. +- [x] Signing, authorization, simulation, and submission are documented. +- [x] Amounts, timestamps, networks, errors, and public API are documented. +- [x] Transaction tracking and backend indexing responsibilities are + documented. +- [x] Versioning, generated-binding provenance, and troubleshooting are + documented. +- [x] All internal documentation links pass an automated check. +- [ ] The GitBook space is connected to this repository and its public URL is + added to `package.json` and the repository README. + +## 3. Example readiness + +- [x] Examples use `@fundable/sdk` exactly as an external application does. +- [x] Shared environment configuration validates all required values. +- [x] Read a Flow stream and format its token amounts. +- [x] Create a Flow through the Router. +- [x] Create a Lockup through the Router. +- [x] Withdraw from an NFT-backed stream. +- [x] Read Stream NFT ownership and stream metadata. +- [x] Check an allowed Paymaster fee token. +- [x] Demonstrate wallet-controlled signing and submission without embedding + a secret key. +- [ ] Example source files pass TypeScript checking in CI. + +## 4. Release acceptance + +- [ ] Unit tests, type checking, build, documentation checks, and example + checks pass in CI. +- [x] The packed tarball contains only the intended runtime, type, README, and + documentation files. +- [x] A clean project can install the packed SDK and compile a representative + consumer. +- [x] A testnet smoke test verifies documented read and simulation workflows. +- [ ] GitBook pages render correctly on desktop and mobile. +- [ ] The stable npm package, GitBook URL, examples, source tag, and contract + provenance all cross-link to one another. + +## Delivery gate + +Do not call the SDK deliverable complete until every release-acceptance item is +checked. Publishing a prerelease proves the npm pipeline, but it does not +replace stable-package, documentation, and clean-consumer acceptance. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..65d17b2 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,25 @@ +# Example implementations + +The repository includes runnable TypeScript examples that import the public +package boundary: + +| Example | Purpose | +| --- | --- | +| `read-flow.ts` | Read normalized Flow state and format exact amounts. | +| `create-router-flow.ts` | Simulate NFT-backed Flow creation. | +| `create-router-lockup.ts` | Simulate an NFT-backed Lockup schedule. | +| `withdraw-router.ts` | Simulate a Router withdrawal by Stream NFT token ID. | +| `read-stream-nft.ts` | Read ownership and underlying stream metadata. | +| `check-paymaster.ts` | Read the Paymaster fee-token allowlist. | +| `wallet-sign-and-submit.ts` | Integrate wallet callbacks without a secret key. | + +See the repository's [examples README](../examples/README.md) for environment +variables and commands. Write examples simulate and print XDR but do not +submit. This makes them safe to run while still verifying contract addresses, +inputs, source accounts, RPC connectivity, and transaction assembly. + +Run the static acceptance check with: + +```bash +pnpm examples:typecheck +``` diff --git a/docs/flows.md b/docs/flows.md new file mode 100644 index 0000000..ba240d6 --- /dev/null +++ b/docs/flows.md @@ -0,0 +1,90 @@ +# Flow streams + +The Flow client talks directly to the continuous-stream engine. Router-created +Flow streams are represented by Stream NFTs; direct Flow engine writes are +available for integrations that deliberately do not use that NFT composition. + +All identifiers accept `string | bigint`, all on-chain amounts use `bigint`, +and timestamps are Unix seconds represented by `bigint` or JavaScript `Date`. + +## Read a stream + +```ts +import { formatUnits } from "@fundable/sdk/core"; + +const stream = await fundable.flows.getStream("42"); +const withdrawable = await fundable.flows.getWithdrawableAmount("42"); + +console.log({ + sender: stream.sender, + recipient: stream.recipient, + status: await fundable.flows.getStatus("42"), + balance: formatUnits(stream.balance, stream.token.decimals), + withdrawable: formatUnits(withdrawable, stream.token.decimals), +}); +``` + +Other read methods are `getBalance`, `getRatePerSecond`, +`getRefundableAmount`, `getTotalDebt`, `getCoveredDebt`, +`getUncoveredDebt`, and `getDepletionTime`. + +## Create and fund + +```ts +import { parseUnits } from "@fundable/sdk/core"; + +const transaction = await fundable.flows.createAndDeposit({ + sender: connectedAccount, + recipient, + token: { address: tokenContract, decimals: 7, symbol: "USDC" }, + ratePerSecond: parseUnits("0.001", 7), + amount: parseUnits("100", 7), + startTime: new Date(), +}); + +const sent = await transaction.signAndSend(); +console.log(sent.result); // stream ID +``` + +Use `create` when funding will happen separately and `deposit` to add balance +to an existing stream. + +## Manage a stream + +```ts +await ( + await fundable.flows.pause({ streamId: "42", actor: connectedAccount }) +).signAndSend(); + +await ( + await fundable.flows.restart({ + streamId: "42", + actor: connectedAccount, + ratePerSecond: parseUnits("0.002", 7), + }) +).signAndSend(); +``` + +The management methods are: + +| Method | Actor and effect | +| --- | --- | +| `adjustRate` | Sender changes the ongoing rate. | +| `pause` | Sender pauses accrual. | +| `restart` | Sender restarts with a positive rate. | +| `refund` | Sender returns a specified refundable amount. | +| `refundMax` | Sender returns the maximum refundable amount. | +| `withdraw` | Authorized caller sends a specified amount to `to`. | +| `withdrawMax` | Authorized caller sends the maximum amount to `to`. | +| `void` | Authorized caller permanently voids the stream. | + +The contract remains the authority for permissions and available amounts. SDK +validation catches malformed inputs before simulation but does not replace +on-chain authorization. + +## Router or direct Flow? + +Use `fundable.router.createFlow` for Fundable's NFT-backed product workflow. +Use `fundable.flows.create` or `createAndDeposit` only when the application +explicitly needs a direct engine stream. Withdrawal from an NFT-backed stream +uses the Router token ID, not the engine stream ID. diff --git a/docs/getting-started.md b/docs/getting-started.md index be06564..f4c197f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -6,6 +6,13 @@ pnpm add @fundable/sdk @stellar/stellar-sdk ``` +The SDK requires Node.js 20 or newer. npm and Yarn can install the same public +package: + +```bash +npm install @fundable/sdk @stellar/stellar-sdk +``` + ## Configure Stellar ```ts @@ -22,6 +29,10 @@ const fundable = createFundableClient({ }); ``` +Use environment-specific deployment addresses rather than copying the +placeholders. See [Configuration](configuration.md) for optional Router, Stream +NFT, Paymaster, signing, and server settings. + Read calls return decoded values: ```ts @@ -30,7 +41,8 @@ const withdrawable = await fundable.flows.getWithdrawableAmount("42"); ``` Write calls return a Stellar `AssembledTransaction`, allowing the application -to choose its wallet and authorization workflow: +to choose its wallet and authorization workflow. Configure the client with a +connected public key and signing callbacks before submission: ```ts import { parseUnits } from "@fundable/sdk/core"; @@ -47,4 +59,5 @@ const result = await transaction.signAndSend(); Router-based NFT creation, Stream NFT reads, and sponsored Paymaster execution are available when their contract IDs are supplied in the client configuration. See [Router, Stream NFT, and Paymaster](./router-paymaster.md) for the complete -surface. +surface and [Transactions](transactions.md) for authorization and result +handling. diff --git a/docs/gitbook-publishing.md b/docs/gitbook-publishing.md new file mode 100644 index 0000000..26981bf --- /dev/null +++ b/docs/gitbook-publishing.md @@ -0,0 +1,45 @@ +# Publishing this documentation with GitBook + +The repository is ready for GitBook Git Sync: + +- `.gitbook.yaml` selects `docs/` as the content root; +- `docs/README.md` is the documentation homepage; +- `docs/SUMMARY.md` defines the public navigation; +- `pnpm docs:check` verifies local Markdown targets. + +## One-time organization setup + +A Fundable GitBook organization owner must: + +1. Create or select the public SDK documentation space. +2. Install or authorize the GitHub integration for + `Fundable-Protocol/fundable-sdk`. +3. Enable Git Sync for the repository's `main` branch. +4. Confirm that GitBook detects `.gitbook.yaml` at the repository root. +5. Configure the published site title, icon, social preview, and public access. +6. Publish the site and record its canonical HTTPS URL. + +GitBook changes should normally be proposed through repository pull requests. +If bidirectional editing is enabled, protect `main` and require GitBook-originated +changes to pass the same CI checks as code-originated changes. + +## After the first publication + +Replace the package's GitHub `homepage` value with the canonical documentation +URL, then add the same URL to: + +- the repository About section; +- the top of the repository README; +- the npm package page through the next release; +- the stable GitHub release notes. + +Verify every navigation item, code block, table, and external link on desktop +and mobile. Record that verification in the +[delivery checklist](delivery-checklist.md). + +## Release ownership + +Publishing GitBook content is separate from publishing npm. Documentation may +deploy on every merge to `main`, while npm only publishes from an approved +version tag. A stable SDK release must link to documentation that describes +that exact package and compatible contract deployment. diff --git a/docs/multichain-architecture.md b/docs/multichain-architecture.md index bf02600..9df35c7 100644 --- a/docs/multichain-architecture.md +++ b/docs/multichain-architecture.md @@ -40,3 +40,16 @@ Each adapter owns: Adapters may expose native transaction handles for advanced use cases. Common capability names do not require Stellar and EVM transaction internals to be identical. + +## Adding EVM later + +An EVM adapter should implement the shared capability inputs and normalized +records, then own EVM-specific addresses, chain IDs, ABI encoding, simulation, +gas estimation, wallet clients, transaction receipts, and error mapping. +Applications can continue using shared token, schedule, stream, and amount +types while narrowing to an adapter-native transaction handle when they need +chain-specific behavior. + +Do not force Stellar authorization concepts into the EVM adapter or reduce +both chains to an untyped transaction abstraction. The stable boundary is the +Fundable operation and result model, not identical wallet internals. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..76db579 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,69 @@ +# Release process + +Fundable SDK releases are produced from reviewed commits on `main`. npm +publishing is automated by a version tag and uses trusted publishing; no npm +token belongs in the repository. + +## Stable-release gate + +Before choosing a stable version: + +- every automated item in the [delivery checklist](delivery-checklist.md) is + checked; +- the SDK API and compatible contract release are approved; +- the public GitBook site is live and linked from repository metadata; +- CI is green on the exact release commit; +- the testnet smoke suite passes against the recorded deployment; +- `stellar_client` is green against the release candidate. + +Do not publish a stable package solely because the prerelease is usable. +Stable means the package, documentation, examples, compatibility statement, +and operational ownership are ready together. + +## Prepare the release pull request + +1. Select the semantic version. The first stable candidate is expected to be + `0.1.0` only if maintainers approve the current public API. +2. Update `package.json`, the SDK `VERSION` export, and `pnpm-lock.yaml` to the + same version. +3. Replace prerelease warnings and pinned example versions where applicable. +4. Add release notes describing features, contract compatibility, known + limitations, and migration requirements. +5. Run: + + ```bash + pnpm verify + pnpm package:verify + pnpm test:testnet + ``` + +6. Open a pull request and require the normal CI review. + +## Publish + +After the release pull request is merged: + +1. Create the annotated tag `v` on the reviewed `main` + commit. +2. Push the tag. +3. Confirm the GitHub publish workflow succeeds. +4. Confirm npm shows the exact version under `latest`. A version containing a + hyphen is intentionally published under `next`. +5. Create the GitHub release from the same tag. +6. Verify a fresh external project can install the registry version, not only + the local tarball. + +The workflow rejects a tag that does not exactly match `package.json`. + +## Post-release + +- verify npm, GitHub, GitBook, README, contract provenance, and examples link + to the compatible versions; +- monitor install and RPC errors from Fundable-owned applications; +- reconcile indexed test transactions in the backend; +- create follow-up issues for deferred EVM adapter work without changing the + stable shared boundary. + +If publishing succeeds but a critical package defect is found, do not overwrite +the npm version. Publish a corrected patch and deprecate the affected version +with a clear replacement message. diff --git a/docs/router-paymaster.md b/docs/router-paymaster.md index 78c3453..1005f78 100644 --- a/docs/router-paymaster.md +++ b/docs/router-paymaster.md @@ -30,6 +30,7 @@ The initial surface includes: All write methods return Stellar `AssembledTransaction` objects. Applications retain control over simulation, Soroban authorization, signing, and submission. +See [Transactions and authorization](transactions.md) before enabling writes. `router.createLockup` accepts chain-neutral schedule values and validates the time range, optional cliff, unlock amounts, and granularity before simulation: @@ -48,6 +49,40 @@ const transaction = await fundable.router?.createLockup({ }); ``` +Router withdrawals use the Stream NFT token ID: + +```ts +if (!fundable.router) throw new Error("Router is not configured"); + +const transaction = await fundable.router.withdrawMax({ + tokenId: "7", + caller: connectedAccount, + to: connectedAccount, +}); +``` + +Read Stream NFT ownership and the underlying engine stream mapping: + +```ts +if (!fundable.streamNft) throw new Error("Stream NFT is not configured"); + +const owner = await fundable.streamNft.ownerOf("7"); +const stream = await fundable.streamNft.getStreamData("7"); +``` + +Check fee-token policy before constructing a Paymaster forward: + +```ts +if (!fundable.paymaster) throw new Error("Paymaster is not configured"); + +const allowed = await fundable.paymaster.isFeeTokenAllowed(feeTokenContract); +``` + +`paymaster.forward` is a bounded advanced call. The application must verify the +target contract, function name, raw Soroban arguments, fee recipient, maximum +fee, and expiration ledger before asking the user to authorize. Never expose a +backend endpoint that signs arbitrary forwarding input. + ## Testnet integration The integration suite reads Stream NFT and Paymaster state and simulates Router diff --git a/docs/transactions.md b/docs/transactions.md new file mode 100644 index 0000000..b395add --- /dev/null +++ b/docs/transactions.md @@ -0,0 +1,80 @@ +# Transactions, authorization, and submission + +Write methods return Stellar SDK `AssembledTransaction` objects. The +Fundable SDK validates inputs, constructs the contract invocation, and runs +simulation. Your application still controls authorization, signing, and +submission. + +## Wallet-controlled submission + +Configure the client with the connected wallet's `publicKey`, +`signTransaction`, and `signAuthEntry` callbacks. The resulting transaction can +then complete its required authorization and submit: + +```ts +const transaction = await fundable.router.createFlow({ + sender: connectedAccount, + recipient, + token: { address: tokenContract, decimals: 7 }, + ratePerSecond: 10_000n, +}); + +if ( + transaction + .needsNonInvokerSigningBy() + .includes(connectedAccount) +) { + await transaction.signAuthEntries({ address: connectedAccount }); +} +const sent = await transaction.signAndSend(); + +console.log(sent.sendTransactionResponse?.hash); +console.log(sent.result); +``` + +Some invocations have no additional Soroban authorization entries. Calling +`signAuthEntries` is only required when `transaction.needsNonInvokerSigningBy` +identifies non-invoker addresses that must authorize the call. Each address +must sign through the wallet that controls it; one participant must not attempt +to sign for another. + +## Inspect before signing + +Applications should show the user the action, token, amount, destination, and +network before invoking a wallet: + +```ts +console.log(transaction.toXDR()); +console.log(transaction.simulationData); +console.log(transaction.needsNonInvokerSigningBy()); +``` + +Simulation can become stale as ledger state changes. Submit promptly and +handle a failed or expired transaction by rebuilding it rather than silently +changing the signed operation. + +## Multi-party workflows + +`transaction.toJSON()` serializes the assembled transaction and simulation +data for a controlled multi-party authorization flow. Treat that JSON as an +unsigned transaction proposal: + +- authenticate every participant; +- display and verify the invocation before signing; +- bind authorization to the expected network and expiration ledger; +- do not accept mutated serialized transactions from an untrusted party; +- record the final transaction hash only after submission. + +## Server signing + +A backend may supply signing callbacks when it legitimately owns the source +account or acts as a relayer. Keep the seed in a secrets manager, scope the +account to the smallest necessary authority, enforce policy before signing, +and never expose a generic signing endpoint. + +## Result handling + +The resolved `result` is the decoded contract return value. The transaction +hash in `sendTransactionResponse.hash` is the durable identifier to store for +reconciliation. A frontend success toast is not a substitute for backend +confirmation and event indexing. diff --git a/docs/troubleshooting-and-versioning.md b/docs/troubleshooting-and-versioning.md new file mode 100644 index 0000000..b479d83 --- /dev/null +++ b/docs/troubleshooting-and-versioning.md @@ -0,0 +1,72 @@ +# Troubleshooting and versioning + +## Common problems + +### A capability is undefined + +`router`, `streamNft`, and `paymaster` are optional because their contract IDs +are optional. Add the correct deployed address to the client configuration and +restart the application. + +### Address validation fails + +Use a valid Stellar account (`G...`) or contract (`C...`) address for the +specific parameter. Confirm the address belongs to the configured network and +was not truncated by an environment loader. + +### Simulation fails + +Confirm: + +- the RPC URL serves the configured network passphrase; +- the contract IDs belong to the same deployment; +- the source account exists and has enough XLM for fees and reserves; +- token balances and allowances satisfy the operation; +- timestamps and stream amounts satisfy contract rules; +- the app is using bindings compatible with the deployed contract release. + +Preserve `FundableError.cause` in restricted diagnostic logs, but show users a +safe action-oriented message. + +### A transaction expires or becomes invalid + +Rebuild and re-simulate the invocation against current ledger state. Do not +reuse an old signature on a modified transaction. + +### Amounts are rounded + +Do not pass JavaScript `number` values for token amounts. Parse user input with +`parseUnits` and format results with `formatUnits`. + +## Versioning policy + +The SDK follows semantic versioning: + +- patch releases fix behavior without changing supported public inputs; +- minor releases add backward-compatible capabilities; +- major releases may change public types or behavior. + +Until `1.0.0`, minor `0.x` releases may contain breaking changes. Prerelease +versions such as `0.1.0-alpha.3` are published under npm's `next` tag and must +not be treated as stable. + +Pin an exact prerelease in production: + +```json +{ + "dependencies": { + "@fundable/sdk": "0.1.0-alpha.3" + } +} +``` + +Every release must align: + +- npm package version and dist-tag; +- Git tag and GitHub release; +- documentation version; +- generated binding provenance; +- compatible contract release and deployment addresses. + +Review [Generated Stellar bindings](generated-bindings.md) before upgrading an +application that targets an existing contract deployment. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5e45e55 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,53 @@ +# Fundable SDK examples + +These scripts import `@fundable/sdk` through the same package export used by an +external application. They default to Stellar testnet, simulate write +transactions, and never submit a transaction or load a secret key. + +## Configure + +Copy the example values into your shell or environment manager: + +```bash +export FUNDABLE_RPC_URL="https://soroban-testnet.stellar.org" +export FUNDABLE_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +export FUNDABLE_FLOW_CONTRACT="C..." +export FUNDABLE_ROUTER_CONTRACT="C..." +export FUNDABLE_STREAM_NFT_CONTRACT="C..." +export FUNDABLE_PAYMASTER_CONTRACT="C..." +export FUNDABLE_PUBLIC_KEY="G..." +export FUNDABLE_RECIPIENT="G..." +export FUNDABLE_TOKEN_CONTRACT="C..." +``` + +Only the values needed by a selected example are required. Read examples do +not require `FUNDABLE_PUBLIC_KEY` unless the RPC operation needs a source +account. + +## Run + +From the repository: + +```bash +pnpm example:read-flow +pnpm example:create-router-flow +pnpm example:create-router-lockup +pnpm example:withdraw-router +pnpm example:read-stream-nft +pnpm example:check-paymaster +``` + +The package is built before each command so Node resolves `@fundable/sdk` +through the published export map. + +## Signing + +[`wallet-sign-and-submit.ts`](wallet-sign-and-submit.ts) shows how an +application submits a Router Flow after constructing the client with wallet +callbacks. It intentionally has no secret-key implementation. Browser and +mobile applications should delegate both transaction-envelope and Soroban +authorization signing to their connected wallet. + +Write scripts in this folder stop after successful simulation and print the +assembled XDR plus any non-invoker addresses that need authorization. Review +the transaction before sending it to a wallet. diff --git a/examples/check-paymaster.ts b/examples/check-paymaster.ts new file mode 100644 index 0000000..b61b188 --- /dev/null +++ b/examples/check-paymaster.ts @@ -0,0 +1,12 @@ +import { createExampleClient, requireEnv } from "./config.js"; + +const fundable = createExampleClient({ capability: "paymaster" }); + +if (!fundable.paymaster) { + throw new Error("Paymaster capability is not configured"); +} + +const feeToken = requireEnv("FUNDABLE_FEE_TOKEN_CONTRACT"); +const allowed = await fundable.paymaster.isFeeTokenAllowed(feeToken); + +console.log(JSON.stringify({ feeToken, allowed }, null, 2)); diff --git a/examples/config.ts b/examples/config.ts new file mode 100644 index 0000000..72f8a77 --- /dev/null +++ b/examples/config.ts @@ -0,0 +1,86 @@ +import { + createFundableClient, + type StellarFundableClient, +} from "@fundable/sdk"; + +const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"; + +type Capability = "router" | "streamNft" | "paymaster"; + +export function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export function optionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim(); + return value || undefined; +} + +export function createExampleClient(options?: { + capability?: Capability; + requirePublicKey?: boolean; +}): StellarFundableClient { + const publicKey = options?.requirePublicKey + ? requireEnv("FUNDABLE_PUBLIC_KEY") + : optionalEnv("FUNDABLE_PUBLIC_KEY"); + + const router = optionalEnv("FUNDABLE_ROUTER_CONTRACT"); + const streamNft = optionalEnv("FUNDABLE_STREAM_NFT_CONTRACT"); + const paymaster = optionalEnv("FUNDABLE_PAYMASTER_CONTRACT"); + + const requiredAddress = + options?.capability === "router" + ? router + : options?.capability === "streamNft" + ? streamNft + : options?.capability === "paymaster" + ? paymaster + : undefined; + + if (options?.capability && !requiredAddress) { + const environmentName = { + router: "FUNDABLE_ROUTER_CONTRACT", + streamNft: "FUNDABLE_STREAM_NFT_CONTRACT", + paymaster: "FUNDABLE_PAYMASTER_CONTRACT", + }[options.capability]; + throw new Error(`Missing required environment variable: ${environmentName}`); + } + + return createFundableClient({ + chain: "stellar", + network: "testnet", + rpcUrl: + optionalEnv("FUNDABLE_RPC_URL") ?? + "https://soroban-testnet.stellar.org", + networkPassphrase: + optionalEnv("FUNDABLE_NETWORK_PASSPHRASE") ?? TESTNET_PASSPHRASE, + publicKey, + contracts: { + flow: requireEnv("FUNDABLE_FLOW_CONTRACT"), + router, + streamNft, + paymaster, + }, + }); +} + +export function printSimulation(transaction: { + toXDR(): string; + needsNonInvokerSigningBy(): string[]; +}): void { + console.log( + JSON.stringify( + { + transactionXdr: transaction.toXDR(), + nonInvokerSigners: transaction.needsNonInvokerSigningBy(), + submitted: false, + }, + null, + 2, + ), + ); +} diff --git a/examples/create-router-flow.ts b/examples/create-router-flow.ts new file mode 100644 index 0000000..390ea46 --- /dev/null +++ b/examples/create-router-flow.ts @@ -0,0 +1,34 @@ +import { parseUnits } from "@fundable/sdk/core"; +import { + createExampleClient, + printSimulation, + requireEnv, +} from "./config.js"; + +const fundable = createExampleClient({ + capability: "router", + requirePublicKey: true, +}); + +if (!fundable.router) { + throw new Error("Router capability is not configured"); +} + +const tokenDecimals = Number( + process.env.FUNDABLE_TOKEN_DECIMALS?.trim() || "7", +); +const transaction = await fundable.router.createFlow({ + sender: requireEnv("FUNDABLE_PUBLIC_KEY"), + recipient: requireEnv("FUNDABLE_RECIPIENT"), + token: { + address: requireEnv("FUNDABLE_TOKEN_CONTRACT"), + decimals: tokenDecimals, + }, + ratePerSecond: parseUnits( + process.env.FUNDABLE_RATE_PER_SECOND?.trim() || "0.001", + tokenDecimals, + ), + startTime: new Date(), +}); + +printSimulation(transaction); diff --git a/examples/create-router-lockup.ts b/examples/create-router-lockup.ts new file mode 100644 index 0000000..1bdc00e --- /dev/null +++ b/examples/create-router-lockup.ts @@ -0,0 +1,41 @@ +import { parseUnits } from "@fundable/sdk/core"; +import { + createExampleClient, + printSimulation, + requireEnv, +} from "./config.js"; + +const fundable = createExampleClient({ + capability: "router", + requirePublicKey: true, +}); + +if (!fundable.router) { + throw new Error("Router capability is not configured"); +} + +const tokenDecimals = Number( + process.env.FUNDABLE_TOKEN_DECIMALS?.trim() || "7", +); +const startTime = BigInt(Math.floor(Date.now() / 1_000) + 300); +const durationSeconds = 30n * 24n * 60n * 60n; + +const transaction = await fundable.router.createLockup({ + sender: requireEnv("FUNDABLE_PUBLIC_KEY"), + recipient: requireEnv("FUNDABLE_RECIPIENT"), + token: { + address: requireEnv("FUNDABLE_TOKEN_CONTRACT"), + decimals: tokenDecimals, + }, + totalAmount: parseUnits( + process.env.FUNDABLE_TOTAL_AMOUNT?.trim() || "100", + tokenDecimals, + ), + startTime, + endTime: startTime + durationSeconds, + cliffTime: startTime + 7n * 24n * 60n * 60n, + granularitySeconds: 3_600n, + cancelable: true, +}); + +printSimulation(transaction); diff --git a/examples/read-flow.ts b/examples/read-flow.ts new file mode 100644 index 0000000..7e9646c --- /dev/null +++ b/examples/read-flow.ts @@ -0,0 +1,31 @@ +import { formatUnits } from "@fundable/sdk/core"; +import { createExampleClient, requireEnv } from "./config.js"; + +const fundable = createExampleClient(); +const streamId = requireEnv("FUNDABLE_FLOW_STREAM_ID"); + +const [stream, status, withdrawable] = await Promise.all([ + fundable.flows.getStream(streamId), + fundable.flows.getStatus(streamId), + fundable.flows.getWithdrawableAmount(streamId), +]); + +console.log( + JSON.stringify( + { + id: stream.id, + sender: stream.sender, + recipient: stream.recipient, + status, + token: stream.token.address, + balance: formatUnits(stream.balance, stream.token.decimals), + ratePerSecond: formatUnits( + stream.ratePerSecond, + stream.token.decimals, + ), + withdrawable: formatUnits(withdrawable, stream.token.decimals), + }, + null, + 2, + ), +); diff --git a/examples/read-stream-nft.ts b/examples/read-stream-nft.ts new file mode 100644 index 0000000..21d9902 --- /dev/null +++ b/examples/read-stream-nft.ts @@ -0,0 +1,15 @@ +import { createExampleClient, requireEnv } from "./config.js"; + +const fundable = createExampleClient({ capability: "streamNft" }); + +if (!fundable.streamNft) { + throw new Error("Stream NFT capability is not configured"); +} + +const tokenId = requireEnv("FUNDABLE_STREAM_NFT_TOKEN_ID"); +const [owner, stream] = await Promise.all([ + fundable.streamNft.ownerOf(tokenId), + fundable.streamNft.getStreamData(tokenId), +]); + +console.log(JSON.stringify({ owner, ...stream }, null, 2)); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..1667d02 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["./*.ts"] +} diff --git a/examples/wallet-sign-and-submit.ts b/examples/wallet-sign-and-submit.ts new file mode 100644 index 0000000..1522abb --- /dev/null +++ b/examples/wallet-sign-and-submit.ts @@ -0,0 +1,32 @@ +import type { + CreateFlowInput, + StellarFundableClient, +} from "@fundable/sdk"; + +/** + * Submit a Router Flow with the wallet callbacks supplied when `fundable` was + * created. This function never receives or stores a secret key. + */ +export async function createRouterFlowWithWallet( + fundable: StellarFundableClient, + input: CreateFlowInput, +): Promise<{ tokenId: bigint; transactionHash?: string }> { + if (!fundable.router) { + throw new Error("Router capability is not configured"); + } + + const transaction = await fundable.router.createFlow(input); + const nonInvokerSigners = transaction.needsNonInvokerSigningBy(); + + if (nonInvokerSigners.length > 0) { + throw new Error( + `Collect Soroban authorization from: ${nonInvokerSigners.join(", ")}`, + ); + } + + const sent = await transaction.signAndSend(); + return { + tokenId: sent.result, + transactionHash: sent.sendTransactionResponse?.hash, + }; +} diff --git a/examples/withdraw-router.ts b/examples/withdraw-router.ts new file mode 100644 index 0000000..ac2b341 --- /dev/null +++ b/examples/withdraw-router.ts @@ -0,0 +1,28 @@ +import { parseUnits } from "@fundable/sdk/core"; +import { + createExampleClient, + printSimulation, + requireEnv, +} from "./config.js"; + +const fundable = createExampleClient({ + capability: "router", + requirePublicKey: true, +}); + +if (!fundable.router) { + throw new Error("Router capability is not configured"); +} + +const transaction = await fundable.router.withdraw({ + tokenId: requireEnv("FUNDABLE_STREAM_NFT_TOKEN_ID"), + caller: requireEnv("FUNDABLE_PUBLIC_KEY"), + to: process.env.FUNDABLE_WITHDRAW_TO?.trim() || + requireEnv("FUNDABLE_PUBLIC_KEY"), + amount: parseUnits( + process.env.FUNDABLE_WITHDRAW_AMOUNT?.trim() || "1", + Number(process.env.FUNDABLE_TOKEN_DECIMALS?.trim() || "7"), + ), +}); + +printSimulation(transaction); diff --git a/package.json b/package.json index 6b5528c..d0974bb 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "files": [ "dist", "docs", + "examples", "README.md" ], "homepage": "https://github.com/Fundable-Protocol/fundable-sdk#readme", @@ -50,7 +51,17 @@ "test": "vitest run", "test:testnet": "FUNDABLE_TESTNET_INTEGRATION=1 vitest run src/stellar/testnet.integration.test.ts", "typecheck": "tsc --noEmit", - "prepack": "pnpm test && pnpm build" + "docs:check": "node scripts/check-doc-links.mjs", + "examples:typecheck": "pnpm build && tsc -p examples/tsconfig.json", + "example:read-flow": "pnpm build && tsx examples/read-flow.ts", + "example:create-router-flow": "pnpm build && tsx examples/create-router-flow.ts", + "example:create-router-lockup": "pnpm build && tsx examples/create-router-lockup.ts", + "example:withdraw-router": "pnpm build && tsx examples/withdraw-router.ts", + "example:read-stream-nft": "pnpm build && tsx examples/read-stream-nft.ts", + "example:check-paymaster": "pnpm build && tsx examples/check-paymaster.ts", + "package:verify": "node scripts/verify-packed-package.mjs", + "verify": "pnpm test && pnpm typecheck && pnpm examples:typecheck && pnpm docs:check", + "prepack": "pnpm verify" }, "engines": { "node": ">=20" @@ -61,8 +72,9 @@ "buffer": "^6.0.3" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", + "tsx": "^4.23.1", + "typescript": "^5", "vitest": "^3.2.4" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468e836..1d1d968 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,12 +18,15 @@ importers: '@types/node': specifier: ^20 version: 20.19.43 + tsx: + specifier: ^4.23.1 + version: 4.23.1 typescript: specifier: ^5 version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.6(@types/node@20.19.43) + version: 3.2.6(@types/node@20.19.43)(tsx@4.23.1) packages: @@ -704,6 +707,11 @@ packages: toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -1011,13 +1019,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.6(vite@7.3.6(@types/node@20.19.43))': + '@vitest/mocker@3.2.6(vite@7.3.6(@types/node@20.19.43)(tsx@4.23.1))': dependencies: '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@20.19.43) + vite: 7.3.6(@types/node@20.19.43)(tsx@4.23.1) '@vitest/pretty-format@3.2.6': dependencies: @@ -1396,6 +1404,12 @@ snapshots: toml@3.0.0: {} + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -1408,13 +1422,13 @@ snapshots: urijs@1.19.11: {} - vite-node@3.2.4(@types/node@20.19.43): + vite-node@3.2.4(@types/node@20.19.43)(tsx@4.23.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@20.19.43) + vite: 7.3.6(@types/node@20.19.43)(tsx@4.23.1) transitivePeerDependencies: - '@types/node' - jiti @@ -1429,7 +1443,7 @@ snapshots: - tsx - yaml - vite@7.3.6(@types/node@20.19.43): + vite@7.3.6(@types/node@20.19.43)(tsx@4.23.1): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) @@ -1440,12 +1454,13 @@ snapshots: optionalDependencies: '@types/node': 20.19.43 fsevents: 2.3.3 + tsx: 4.23.1 - vitest@3.2.6(@types/node@20.19.43): + vitest@3.2.6(@types/node@20.19.43)(tsx@4.23.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 - '@vitest/mocker': 3.2.6(vite@7.3.6(@types/node@20.19.43)) + '@vitest/mocker': 3.2.6(vite@7.3.6(@types/node@20.19.43)(tsx@4.23.1)) '@vitest/pretty-format': 3.2.6 '@vitest/runner': 3.2.6 '@vitest/snapshot': 3.2.6 @@ -1463,8 +1478,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@20.19.43) - vite-node: 3.2.4(@types/node@20.19.43) + vite: 7.3.6(@types/node@20.19.43)(tsx@4.23.1) + vite-node: 3.2.4(@types/node@20.19.43)(tsx@4.23.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs new file mode 100644 index 0000000..01905d9 --- /dev/null +++ b/scripts/check-doc-links.mjs @@ -0,0 +1,89 @@ +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; + +const repositoryRoot = process.cwd(); +const ignoredDirectories = new Set([".git", "dist", "node_modules"]); +const markdownLinkPattern = /!?\[[^\]]*]\(([^)]+)\)/g; +const failures = []; + +async function collectMarkdownFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + if (entry.isDirectory() && ignoredDirectories.has(entry.name)) { + continue; + } + + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectMarkdownFiles(absolutePath))); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + files.push(absolutePath); + } + } + + return files; +} + +function normalizeDestination(rawDestination) { + const withoutTitle = rawDestination.trim().replace( + /^<([^>]+)>$|^(\S+)(?:\s+["'][^"']*["'])$/, + (_, angled, plain) => angled ?? plain, + ); + return withoutTitle.split("#", 1)[0].split("?", 1)[0]; +} + +async function exists(target) { + try { + await stat(target); + return true; + } catch { + return false; + } +} + +for (const markdownFile of await collectMarkdownFiles(repositoryRoot)) { + const contents = await readFile(markdownFile, "utf8"); + + for (const match of contents.matchAll(markdownLinkPattern)) { + const destination = normalizeDestination(match[1]); + if ( + !destination || + destination.startsWith("#") || + /^[a-z][a-z\d+.-]*:/i.test(destination) + ) { + continue; + } + + let decodedDestination; + try { + decodedDestination = decodeURIComponent(destination); + } catch { + failures.push( + `${path.relative(repositoryRoot, markdownFile)}: invalid URL encoding in ${destination}`, + ); + continue; + } + + const target = path.resolve(path.dirname(markdownFile), decodedDestination); + if (!target.startsWith(`${repositoryRoot}${path.sep}`) && target !== repositoryRoot) { + failures.push( + `${path.relative(repositoryRoot, markdownFile)}: link escapes repository: ${destination}`, + ); + continue; + } + if (!(await exists(target))) { + failures.push( + `${path.relative(repositoryRoot, markdownFile)}: missing target ${destination}`, + ); + } + } +} + +if (failures.length > 0) { + console.error(failures.join("\n")); + process.exitCode = 1; +} else { + console.log("All local Markdown links resolve."); +} diff --git a/scripts/verify-packed-package.mjs b/scripts/verify-packed-package.mjs new file mode 100644 index 0000000..b50c334 --- /dev/null +++ b/scripts/verify-packed-package.mjs @@ -0,0 +1,158 @@ +import { execFileSync } from "node:child_process"; +import { + mkdir, + mkdtemp, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const repositoryRoot = process.cwd(); +const temporaryRoot = await mkdtemp( + path.join(tmpdir(), "fundable-sdk-package-"), +); +const packedDirectory = path.join(temporaryRoot, "packed"); +const consumerDirectory = path.join(temporaryRoot, "consumer"); + +function run(command, args, cwd = repositoryRoot) { + execFileSync(command, args, { + cwd, + env: process.env, + stdio: "inherit", + }); +} + +try { + await mkdir(packedDirectory, { recursive: true }); + run("pnpm", ["pack", "--pack-destination", packedDirectory]); + + const archiveName = (await readdir(packedDirectory)).find((name) => + name.endsWith(".tgz"), + ); + if (!archiveName) { + throw new Error("pnpm pack did not create a package archive"); + } + + const archivePath = path.join(packedDirectory, archiveName); + const archiveEntries = execFileSync("tar", ["-tzf", archivePath], { + encoding: "utf8", + }) + .trim() + .split("\n"); + + const requiredEntries = [ + "package/package.json", + "package/README.md", + "package/dist/index.js", + "package/dist/index.d.ts", + "package/docs/README.md", + "package/examples/read-flow.ts", + ]; + for (const requiredEntry of requiredEntries) { + if (!archiveEntries.includes(requiredEntry)) { + throw new Error(`Packed SDK is missing ${requiredEntry}`); + } + } + + const forbiddenEntries = ["package/src/", "package/node_modules/"]; + for (const forbiddenEntry of forbiddenEntries) { + if (archiveEntries.some((entry) => entry.startsWith(forbiddenEntry))) { + throw new Error(`Packed SDK unexpectedly contains ${forbiddenEntry}`); + } + } + + await writeFile( + path.join(temporaryRoot, "package-contents.txt"), + `${archiveEntries.join("\n")}\n`, + ); + + await mkdir(consumerDirectory, { recursive: true }); + await writeFile( + path.join(consumerDirectory, "package.json"), + JSON.stringify( + { + name: "fundable-sdk-clean-consumer", + private: true, + type: "module", + }, + null, + 2, + ), + ); + await writeFile( + path.join(consumerDirectory, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ESNext", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + noEmit: true, + skipLibCheck: true, + }, + include: ["consumer.ts"], + }, + null, + 2, + ), + ); + await writeFile( + path.join(consumerDirectory, "consumer.ts"), + `import { + createFundableClient, + type StellarFundableClientConfig, +} from "@fundable/sdk"; +import { formatUnits, parseUnits } from "@fundable/sdk/core"; + +const config: StellarFundableClientConfig = { + chain: "stellar", + network: "testnet", + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contracts: { flow: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" }, +}; + +const amount: bigint = parseUnits("1.25", 7); +const display: string = formatUnits(amount, 7); +const createClient: typeof createFundableClient = createFundableClient; + +void config; +void display; +void createClient; +`, + ); + + run( + "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--save-exact", + archivePath, + "typescript", + ], + consumerDirectory, + ); + run("npx", ["tsc", "-p", "tsconfig.json"], consumerDirectory); + run( + "node", + [ + "--input-type=module", + "--eval", + `import { formatUnits, parseUnits } from "@fundable/sdk/core"; +if (formatUnits(parseUnits("1.25", 7), 7) !== "1.25") process.exit(1);`, + ], + consumerDirectory, + ); + + console.log( + `Packed package verified with ${archiveEntries.length} files and a clean TypeScript consumer.`, + ); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} From 72b66a924cec98c916f0f9a873d0f206a7f43ff7 Mon Sep 17 00:00:00 2001 From: Lawal Abubakar Babatunde Date: Sun, 26 Jul 2026 12:29:58 +0100 Subject: [PATCH 2/2] docs: record SDK CI acceptance --- docs/delivery-checklist.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/delivery-checklist.md b/docs/delivery-checklist.md index f48534c..a1a8f4e 100644 --- a/docs/delivery-checklist.md +++ b/docs/delivery-checklist.md @@ -50,11 +50,11 @@ artifact, an automated check, or a public URL that proves completion. - [x] Check an allowed Paymaster fee token. - [x] Demonstrate wallet-controlled signing and submission without embedding a secret key. -- [ ] Example source files pass TypeScript checking in CI. +- [x] Example source files pass TypeScript checking in CI. ## 4. Release acceptance -- [ ] Unit tests, type checking, build, documentation checks, and example +- [x] Unit tests, type checking, build, documentation checks, and example checks pass in CI. - [x] The packed tarball contains only the intended runtime, type, README, and documentation files.