|
| 1 | +# ARCHITECTURE — @web3settle/merchant-sdk |
| 2 | + |
| 3 | +> Living architecture overview for the embeddable Web3Settle payment SDK, per |
| 4 | +> the [`Discipline.md`](./Discipline.md) charter (§1, §9). For the *why* behind |
| 5 | +> the load-bearing decisions, see [`docs/adr/`](./docs/adr/README.md). |
| 6 | +
|
| 7 | +## What this is |
| 8 | + |
| 9 | +`@web3settle/merchant-sdk` is a **React + framework-agnostic TypeScript SDK** a |
| 10 | +merchant embeds in their storefront to accept **non-custodial crypto payments** |
| 11 | +across **EVM, Solana, and TRON**. It is published to a package registry and |
| 12 | +**runs in the end customer's browser**. Its core job: take a USD amount, obtain a |
| 13 | +verified payment-config and quote from the Web3Settle gateway, build the chain- |
| 14 | +specific transaction, and drive the user's wallet to sign and broadcast a |
| 15 | +payment **directly into the merchant's `MerchantPayIn` contract** (the platform |
| 16 | +never custodies funds — root platform |
| 17 | +[ADR-0001](https://github.com/web3settle/web3settle/blob/main/docs/adr/0001-non-custodial-per-merchant-contracts.md)). |
| 18 | + |
| 19 | +Because it executes in a hostile environment (attacker-reachable network, the |
| 20 | +customer's device), the **trust model and what the SDK refuses to do** are as |
| 21 | +central to its architecture as the features it offers. |
| 22 | + |
| 23 | +## Entry points / subpath structure |
| 24 | + |
| 25 | +The package ships **multiple entry points** (`exports` in `package.json`), so a |
| 26 | +consumer pulls in only the chain and rendering style they use. Solana and TRON |
| 27 | +peer deps are **optional** — an EVM-only consumer never bundles them. |
| 28 | + |
| 29 | +| Subpath | Source entry | Purpose | |
| 30 | +|---|---|---| |
| 31 | +| `.` (root) | [`src/index.ts`](./src/index.ts) | EVM React components + hooks, core (api-client, contract, price-feed, telemetry, confirmation policy, types, config), permit utils, i18n | |
| 32 | +| `./solana` | [`src/solana/index.ts`](./src/solana/index.ts) | Solana provider, hooks, components, pipeline, PDA + instruction builders (optional `@solana/*` peers) | |
| 33 | +| `./tron` | [`src/tron/index.ts`](./src/tron/index.ts) | TRON provider, hooks, components, pipeline; detects `window.tronWeb` at runtime (TronLink) | |
| 34 | +| `./headless` | [`src/headless/index.ts`](./src/headless/index.ts) | Framework-agnostic controllers (`createPayButtonController`, …) — no React render; for Vue/Svelte/vanilla | |
| 35 | +| `./wc` | [`src/wc/index.ts`](./src/wc/index.ts) | Native `<web3settle-pay-button>` Web Component (no Lit), reuses the headless controller | |
| 36 | +| `./styles.css` | `src/styles.ts` → `dist/styles.css` | Opt-in stylesheet | |
| 37 | + |
| 38 | +Layering (no cycles; UI layers depend inward on core): |
| 39 | + |
| 40 | +``` |
| 41 | +React components (.) Solana (./solana) TRON (./tron) |
| 42 | + \ | / |
| 43 | + Web Component (./wc) → headless controllers (./headless) |
| 44 | + \ | / |
| 45 | + core/ (api-client, contract, pipeline interface, |
| 46 | + payment-config-verifier, canonical-json, |
| 47 | + telemetry, price-feed, ConfirmationPolicy, |
| 48 | + config = baked-in trust anchors, types/zod) |
| 49 | +``` |
| 50 | + |
| 51 | +## Trust model — what is verified before signing or submitting |
| 52 | + |
| 53 | +The SDK trusts **constants baked into the published artifact**, and treats |
| 54 | +**everything arriving over the network as hostile** until it proves itself |
| 55 | +against those constants. See [ADR-0003](./docs/adr/0003-browser-trust-boundary-signed-config.md), |
| 56 | +[ADR-0004](./docs/adr/0004-known-permit-tokens-allowlist.md), |
| 57 | +[ADR-0005](./docs/adr/0005-telemetry-redaction-no-phone-home.md). |
| 58 | + |
| 59 | +**Payment-config gate** — `Web3SettleApiClient.fetchPaymentConfig` |
| 60 | +([`src/core/api-client.ts`](./src/core/api-client.ts)) fails closed unless **all |
| 61 | +four** pass, in order: |
| 62 | + |
| 63 | +1. **Shape** — Zod-parse the `{ data, signedAt, signature }` envelope. |
| 64 | +2. **Provenance** — `verifyPaymentConfig` checks an **Ed25519** signature over |
| 65 | + `signed_at + canonical_json(data)` against the **baked-in pubkey(s)** |
| 66 | + (primary + secondary rotation slot). Fully local; no runtime fetch decides |
| 67 | + trust. ([`src/core/payment-config-verifier.ts`](./src/core/payment-config-verifier.ts), |
| 68 | + [`src/core/canonical-json.ts`](./src/core/canonical-json.ts)) |
| 69 | +3. **Replay window** — `signed_at` within `PAYMENT_CONFIG_MAX_AGE_MS` (5 min). |
| 70 | +4. **Allow-lists** — per-chain `contractAddress` ∈ baked-in |
| 71 | + `KNOWN_CONTRACT_ADDRESSES` **or** elevated by the signed payload; and |
| 72 | + `contractAbiVersion` ∈ `SUPPORTED_ABI_VERSIONS`. ([`src/core/config.ts`](./src/core/config.ts)) |
| 73 | + |
| 74 | +**Permit signing gate** — `signPermit` |
| 75 | +([`src/evm/permit.ts`](./src/evm/permit.ts)) refuses, **before any wallet popup**, |
| 76 | +any token whose EIP-712 domain digest is not in `KNOWN_PERMIT_TOKENS` |
| 77 | +(typo-squat defence), and additionally enforces a 60-min deadline cap, owner== |
| 78 | +wallet binding, live-chainId cross-check, and EIP-2 low-s signature validation. |
| 79 | + |
| 80 | +**Outbound data gate (telemetry)** — the SDK makes **no** telemetry network |
| 81 | +calls; failure breadcrumbs go only to an optional synchronous merchant callback, |
| 82 | +carry a **salted non-reversible** wallet digest (never the address), and run |
| 83 | +every free-text message through `redactErrorMessage` (strips addresses, tx |
| 84 | +hashes, UUIDs, base58, filesystem paths, long hex; 240-char cap). |
| 85 | + |
| 86 | +Net effect: a poisoned-DNS/CDN/proxy/extension MITM, a partial backend |
| 87 | +compromise, or a look-alike token cannot get the user to sign against an attacker |
| 88 | +contract or hand out a bearer allowance — the SDK refuses first. Conversely, the |
| 89 | +SDK **fails closed**: a botched key rotation or an unpopulated allow-list refuses |
| 90 | +payments rather than accepting unverified ones. |
| 91 | + |
| 92 | +## Multi-chain adapters |
| 93 | + |
| 94 | +A single `PaymentPipeline` interface ([`src/core/pipeline.ts`](./src/core/pipeline.ts)) |
| 95 | +abstracts the three chain stacks so merchant UIs never branch on `chainId`: |
| 96 | + |
| 97 | +| Family | Stack | Approval step | Amount unit | Tx-hash shape | |
| 98 | +|---|---|---|---|---| |
| 99 | +| EVM | wagmi + viem | yes (non-native, when allowance < amount) | wei | hex | |
| 100 | +| Solana | `@solana/web3.js`; hand-rolled instructions + PDA derivation ([`src/solana/pda.ts`](./src/solana/pda.ts)) | **no** (transfer signed inline) | lamports / SPL raw | base58 | |
| 101 | +| TRON | runtime `window.tronWeb` | yes (non-native) | sun | base58 | |
| 102 | + |
| 103 | +Cross-chain **finality** is unified by `ConfirmationPolicy` |
| 104 | +([`src/core/ConfirmationPolicy.ts`](./src/core/ConfirmationPolicy.ts)): EVM block |
| 105 | +confirmations, Solana commitment levels, TRON confirmed-tx — one "is this safe |
| 106 | +yet?" API. On Solana, per-merchant state is a set of **PDAs** (`merchant_config`, |
| 107 | +`sol_vault`, `token_totals`) whose seeds mirror the Anchor program 1:1 — the |
| 108 | +analogue of the EVM/TRON per-merchant contract address. |
| 109 | + |
| 110 | +## Build & publish |
| 111 | + |
| 112 | +> **Tooling note (override of the charter's "tsup" line).** The |
| 113 | +> [`Discipline.md`](./Discipline.md) §7 example mandates `pnpm` + `tsup`. This |
| 114 | +> repo's **actual, source-of-truth tooling** is **npm + Vite (library mode) + |
| 115 | +> Vitest**, and this document reflects reality per charter rule R8/R11 |
| 116 | +> (calibrated reporting; match conventions, override for correctness). Adopting |
| 117 | +> the charter does **not** retool the build. `Vitest` already aligns with §7. |
| 118 | +
|
| 119 | +- **Build:** `npm run build` → `tsc --noEmit` (typecheck) then **Vite library |
| 120 | + mode** ([`vite.config.ts`](./vite.config.ts)) with `@vitejs/plugin-react`, |
| 121 | + rollup multi-entry `lib`, and **`vite-plugin-dts`** (`rollupTypes`) for bundled |
| 122 | + `.d.ts`. Emits dual **ESM (`.js`) + CJS (`.cjs`)** plus a single `styles.css`. |
| 123 | + React / wagmi / viem / `@solana/*` / `tronweb` are kept **external** (consumers |
| 124 | + supply them). |
| 125 | +- **Test:** **Vitest** (`npm test`, jsdom) — see `src/__tests__/` (api-client, |
| 126 | + payment-config-verifier, permit + permit-allowlist, telemetry, per-chain gas / |
| 127 | + pipeline / PDA, confirmation policy). |
| 128 | +- **Lint/type:** ESLint (flat config) + `tsc --noEmit` strict. |
| 129 | +- **Publish integrity** — `prepublishOnly` runs `npm ci --ignore-scripts` + |
| 130 | + typecheck + lint + test + build; `publishConfig` sets **`provenance: true`**. |
| 131 | + CI ([`.github/workflows/sdk-supplychain.yml`](./.github/workflows/sdk-supplychain.yml)) |
| 132 | + additionally (premortem **F4**): |
| 133 | + - **rejects `^`/`~` on runtime `dependencies`** (exact-version only; devDeps may |
| 134 | + float) so a transitively-compromised minor can't land unreviewed; |
| 135 | + - `npm pack --provenance --dry-run` proves a provenance-signed tarball builds; |
| 136 | + - **asserts the tarball ships no `.ts` source and no `.env`** (only built |
| 137 | + `dist/` JS/CJS + `.d.ts`) so internals/secrets never reach consumers |
| 138 | + (SEC-DEP-006); |
| 139 | + - installs with `--ignore-scripts` so postinstall can't execute in CI. |
| 140 | + |
| 141 | +## Customer-browser → SDK → gateway / chain flow |
| 142 | + |
| 143 | +``` |
| 144 | + END CUSTOMER'S BROWSER (hostile environment) |
| 145 | + ┌──────────────────────────────────────────────────────────────────────┐ |
| 146 | + │ Merchant page │ |
| 147 | + │ ┌───────────────────────── @web3settle/merchant-sdk ───────────────┐ │ |
| 148 | + │ │ PayButton / WebComponent / headless controller │ │ |
| 149 | + │ │ │ │ │ |
| 150 | + │ │ ▼ │ │ |
| 151 | + │ │ Web3SettleApiClient ──(1) GET payment-config / quote ───────────┼─┼──► Web3Settle GATEWAY (API) |
| 152 | + │ │ │ ◄── signed { data, signedAt, signature } ──────────────┼─┼── serves Ed25519-signed config; |
| 153 | + │ │ ▼ │ │ signing key only in Vault |
| 154 | + │ │ TRUST GATE (fail-closed): │ │ |
| 155 | + │ │ shape → Ed25519 vs BAKED-IN pubkey → freshness → addr/ABI │ │ |
| 156 | + │ │ allow-list (ADR-0003) │ │ |
| 157 | + │ │ │ verified contractAddress + amount │ │ |
| 158 | + │ │ ▼ │ │ |
| 159 | + │ │ PaymentPipeline (EVM | Solana | TRON adapter) │ │ |
| 160 | + │ │ • permit? → KNOWN_PERMIT_TOKENS gate + deadline/owner/chain │ │ |
| 161 | + │ │ checks (ADR-0004), else approve() │ │ |
| 162 | + │ │ • build calldata / instruction / trigger │ │ |
| 163 | + │ │ │ │ │ |
| 164 | + │ │ ▼ request signature │ │ |
| 165 | + │ │ USER WALLET (MetaMask / Phantom / TronLink) ── signs ───────────┼─┼──► BLOCKCHAIN |
| 166 | + │ │ │ │ │ funds land DIRECTLY in the |
| 167 | + │ │ ▼ broadcast tx │ │ merchant's MerchantPayIn |
| 168 | + │ │ ConfirmationPolicy: poll to required finality │ │ contract/PDA (non-custodial) |
| 169 | + │ │ │ │ │ |
| 170 | + │ │ telemetry breadcrumb (opt-in, redacted, salted digest, │ │ |
| 171 | + │ │ NO network from SDK) ──► merchant's own analytics (ADR-0005) │ │ |
| 172 | + │ └──────────────────────────────────────────────────────────────────┘ │ |
| 173 | + └──────────────────────────────────────────────────────────────────────┘ |
| 174 | +
|
| 175 | + The gateway only OBSERVES the chain (deposit detection) + relays signed |
| 176 | + webhooks; it is never in the funds hot-path. The chain is the system of |
| 177 | + record. ── Trust anchor: the baked-in SDK artifact, NOT the wire. |
| 178 | +``` |
| 179 | + |
| 180 | +## Pointers |
| 181 | + |
| 182 | +- ADRs: [`docs/adr/README.md`](./docs/adr/README.md) — 0003 browser trust |
| 183 | + boundary, 0004 permit allow-list, 0005 telemetry redaction. |
| 184 | +- Security policy / disclosure: [`SECURITY.md`](./SECURITY.md). |
| 185 | +- Governance charter: [`Discipline.md`](./Discipline.md). |
| 186 | +- Platform-wide ADRs (non-custodial, Compose-only) live in the monorepo root. |
0 commit comments