@latchprotocol/widgets 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +649 -0
  3. package/dist/adapters/binDistribution.d.ts +19 -0
  4. package/dist/adapters/binDistribution.d.ts.map +1 -0
  5. package/dist/adapters/protocol.d.ts +409 -0
  6. package/dist/adapters/protocol.d.ts.map +1 -0
  7. package/dist/adapters/sale.d.ts +142 -0
  8. package/dist/adapters/sale.d.ts.map +1 -0
  9. package/dist/adapters/viem.d.ts +89 -0
  10. package/dist/adapters/viem.d.ts.map +1 -0
  11. package/dist/callpath/constants.d.ts +218 -0
  12. package/dist/callpath/constants.d.ts.map +1 -0
  13. package/dist/callpath/launch.d.ts +1979 -0
  14. package/dist/callpath/launch.d.ts.map +1 -0
  15. package/dist/callpath/liquidity.d.ts +103 -0
  16. package/dist/callpath/liquidity.d.ts.map +1 -0
  17. package/dist/callpath/plan.d.ts +34 -0
  18. package/dist/callpath/plan.d.ts.map +1 -0
  19. package/dist/callpath/swap.d.ts +129 -0
  20. package/dist/callpath/swap.d.ts.map +1 -0
  21. package/dist/chunks/SwapWidget-DgyHAVc8.js +2404 -0
  22. package/dist/chunks/SwapWidget-DgyHAVc8.js.map +1 -0
  23. package/dist/chunks/scan-Dhu7f4Nl.js +2497 -0
  24. package/dist/chunks/scan-Dhu7f4Nl.js.map +1 -0
  25. package/dist/chunks/useLocker-D1c_NTk-.js +1613 -0
  26. package/dist/chunks/useLocker-D1c_NTk-.js.map +1 -0
  27. package/dist/components/LaunchWidget.d.ts +117 -0
  28. package/dist/components/LaunchWidget.d.ts.map +1 -0
  29. package/dist/components/LiquidityWidget.d.ts +33 -0
  30. package/dist/components/LiquidityWidget.d.ts.map +1 -0
  31. package/dist/components/LockerWidget.d.ts +51 -0
  32. package/dist/components/LockerWidget.d.ts.map +1 -0
  33. package/dist/components/LocksWidgets.d.ts +61 -0
  34. package/dist/components/LocksWidgets.d.ts.map +1 -0
  35. package/dist/components/SwapWidget.d.ts +165 -0
  36. package/dist/components/SwapWidget.d.ts.map +1 -0
  37. package/dist/components/TokenTrustPanel.d.ts +32 -0
  38. package/dist/components/TokenTrustPanel.d.ts.map +1 -0
  39. package/dist/components/lockCharts.d.ts +29 -0
  40. package/dist/components/lockCharts.d.ts.map +1 -0
  41. package/dist/components/portal.d.ts +10 -0
  42. package/dist/components/portal.d.ts.map +1 -0
  43. package/dist/components/primitives.d.ts +139 -0
  44. package/dist/components/primitives.d.ts.map +1 -0
  45. package/dist/components/swap/SwapSettings.d.ts +12 -0
  46. package/dist/components/swap/SwapSettings.d.ts.map +1 -0
  47. package/dist/components/swap/TokenSelect.d.ts +61 -0
  48. package/dist/components/swap/TokenSelect.d.ts.map +1 -0
  49. package/dist/components/swap/state.d.ts +208 -0
  50. package/dist/components/swap/state.d.ts.map +1 -0
  51. package/dist/config/chain.d.ts +163 -0
  52. package/dist/config/chain.d.ts.map +1 -0
  53. package/dist/config/integrator.d.ts +121 -0
  54. package/dist/config/integrator.d.ts.map +1 -0
  55. package/dist/context/WidgetProvider.d.ts +78 -0
  56. package/dist/context/WidgetProvider.d.ts.map +1 -0
  57. package/dist/core/clMath.d.ts +98 -0
  58. package/dist/core/clMath.d.ts.map +1 -0
  59. package/dist/core/errors.d.ts +18 -0
  60. package/dist/core/errors.d.ts.map +1 -0
  61. package/dist/core/format.d.ts +47 -0
  62. package/dist/core/format.d.ts.map +1 -0
  63. package/dist/core/liquidityGuards.d.ts +53 -0
  64. package/dist/core/liquidityGuards.d.ts.map +1 -0
  65. package/dist/core/liquidityRefresh.d.ts +35 -0
  66. package/dist/core/liquidityRefresh.d.ts.map +1 -0
  67. package/dist/core/math.d.ts +129 -0
  68. package/dist/core/math.d.ts.map +1 -0
  69. package/dist/core/pool.d.ts +33 -0
  70. package/dist/core/pool.d.ts.map +1 -0
  71. package/dist/core/tokenLists.d.ts +46 -0
  72. package/dist/core/tokenLists.d.ts.map +1 -0
  73. package/dist/embed/iframe.d.ts +185 -0
  74. package/dist/embed/iframe.d.ts.map +1 -0
  75. package/dist/embed/lockBadge.d.ts +55 -0
  76. package/dist/embed/lockBadge.d.ts.map +1 -0
  77. package/dist/embed/webComponent.d.ts +83 -0
  78. package/dist/embed/webComponent.d.ts.map +1 -0
  79. package/dist/embed.d.ts +25 -0
  80. package/dist/embed.d.ts.map +1 -0
  81. package/dist/embed.js +543 -0
  82. package/dist/embed.js.map +1 -0
  83. package/dist/headless.d.ts +60 -0
  84. package/dist/headless.d.ts.map +1 -0
  85. package/dist/headless.js +223 -0
  86. package/dist/headless.js.map +1 -0
  87. package/dist/hooks/useAsyncResource.d.ts +38 -0
  88. package/dist/hooks/useAsyncResource.d.ts.map +1 -0
  89. package/dist/hooks/useLaunch.d.ts +140 -0
  90. package/dist/hooks/useLaunch.d.ts.map +1 -0
  91. package/dist/hooks/useLiquidity.d.ts +91 -0
  92. package/dist/hooks/useLiquidity.d.ts.map +1 -0
  93. package/dist/hooks/useLocker.d.ts +77 -0
  94. package/dist/hooks/useLocker.d.ts.map +1 -0
  95. package/dist/hooks/useLocks.d.ts +33 -0
  96. package/dist/hooks/useLocks.d.ts.map +1 -0
  97. package/dist/hooks/useSwapExecute.d.ts +46 -0
  98. package/dist/hooks/useSwapExecute.d.ts.map +1 -0
  99. package/dist/hooks/useSwapQuote.d.ts +51 -0
  100. package/dist/hooks/useSwapQuote.d.ts.map +1 -0
  101. package/dist/hooks/useTokenLists.d.ts +26 -0
  102. package/dist/hooks/useTokenLists.d.ts.map +1 -0
  103. package/dist/hooks/useTokenTrust.d.ts +40 -0
  104. package/dist/hooks/useTokenTrust.d.ts.map +1 -0
  105. package/dist/hooks/useTokens.d.ts +15 -0
  106. package/dist/hooks/useTokens.d.ts.map +1 -0
  107. package/dist/index.d.ts +59 -0
  108. package/dist/index.d.ts.map +1 -0
  109. package/dist/index.js +1052 -0
  110. package/dist/index.js.map +1 -0
  111. package/dist/latch-embed.d.ts +19 -0
  112. package/dist/latch-embed.d.ts.map +1 -0
  113. package/dist/latch-embed.js +950 -0
  114. package/dist/latch-embed.js.map +1 -0
  115. package/dist/latch-embed.mjs +13162 -0
  116. package/dist/latch-embed.mjs.map +1 -0
  117. package/dist/locker/model.d.ts +73 -0
  118. package/dist/locker/model.d.ts.map +1 -0
  119. package/dist/locks/embedModel.d.ts +22 -0
  120. package/dist/locks/embedModel.d.ts.map +1 -0
  121. package/dist/locks/fetch.d.ts +60 -0
  122. package/dist/locks/fetch.d.ts.map +1 -0
  123. package/dist/locks/model.d.ts +143 -0
  124. package/dist/locks/model.d.ts.map +1 -0
  125. package/dist/locks/scan.d.ts +25 -0
  126. package/dist/locks/scan.d.ts.map +1 -0
  127. package/dist/styles/css.d.ts +32 -0
  128. package/dist/styles/css.d.ts.map +1 -0
  129. package/dist/styles.css +895 -0
  130. package/dist/trust/model.d.ts +48 -0
  131. package/dist/trust/model.d.ts.map +1 -0
  132. package/package.json +102 -0
package/README.md ADDED
@@ -0,0 +1,649 @@
1
+ # @latchprotocol/widgets
2
+
3
+ Embeddable swap, liquidity and launch widgets for **Latch Protocol** — a hooks platform on its own Infinity-architecture core (a singleton Vault with CL and Bin pool managers), deployed and verified per chain.
4
+
5
+ These widgets are built to live in **your** app. Not Latch's. If you run a DEX, an aggregator, a launchpad, a wallet or a portfolio tracker, you can drop one in, keep your own look, and **earn a share of every swap it routes**.
6
+
7
+ MIT licensed. The protocol's Solidity is GPL-2.0; this package is independently authored against the ABIs, so embedding it puts no licence obligation on your app.
8
+
9
+ ---
10
+
11
+ ## The part that matters: integrator fee attribution
12
+
13
+ Every widget takes an `integrator` config:
14
+
15
+ ```tsx
16
+ <WidgetProvider
17
+ adapter={adapter}
18
+ integrator={{
19
+ referrer: "0xYourFeeRecipient", // where your cut lands
20
+ feeBps: 25, // 0.25% of the swap output
21
+ }}
22
+ >
23
+ <SwapWidget />
24
+ </WidgetProvider>
25
+ ```
26
+
27
+ That is the whole integration. From there:
28
+
29
+ - The fee is taken **in the output currency**, out of the swap's own vault credit.
30
+ - It is **an action inside the swap transaction**, not an off-chain accrual, not a claim you file later, not a number in a dashboard someone else controls. It settles atomically with the swap or the swap reverts.
31
+ - It is **shown to the user** as its own line in the summary, and the "minimum received" they are quoted is already net of it.
32
+
33
+ ### How it reaches the chain
34
+
35
+ ```text
36
+ UniversalRouter.execute([INFI_SWAP], [plan], deadline)
37
+
38
+ plan (one vault lock):
39
+ 0. CL_SWAP_EXACT_IN_SINGLE amountOutMinimum = minAmountOutGross
40
+ 1. SETTLE_ALL pull the input currency from the user (via Permit2)
41
+ 2. TAKE_PORTION feeBps of the output credit -> your referrer ← your fee
42
+ 3. TAKE_ALL the remainder -> the user, floored at minAmountOutNet
43
+ ```
44
+
45
+ `TAKE_PORTION` splits the vault credit before anything leaves the singleton, so the router never holds your fee and there is no window in which it can be swept.
46
+
47
+ The code path, end to end:
48
+
49
+ | Step | Where |
50
+ | --- | --- |
51
+ | You pass `integrator` | `<WidgetProvider integrator={...}>` |
52
+ | Validated once, at mount | [`validateIntegratorConfig`](src/config/integrator.ts) |
53
+ | Split out of the quote for display | [`buildQuoteBreakdown`](src/core/math.ts) |
54
+ | Threaded into the execution request | [`useSwapExecute`](src/hooks/useSwapExecute.ts) |
55
+ | Encoded as a `TAKE_PORTION` action | [`buildSwapCall`](src/callpath/swap.ts) |
56
+
57
+ There is no code path that swaps while dropping the fee, and none that encodes a fee that did not pass validation — `buildSwapCall` re-checks the bounds and refuses a hand-constructed config.
58
+
59
+ ### Rules, and what happens when you break them
60
+
61
+ | Rule | On violation |
62
+ | --- | --- |
63
+ | `feeBps` is an integer | `IntegratorConfigError` — `FEE_BPS_NOT_AN_INTEGER` |
64
+ | `0 ≤ feeBps ≤ 100` (**`MAX_INTEGRATOR_FEE_BPS`**, 1.00%) | `FEE_BPS_ABOVE_MAX` |
65
+ | `feeBps > 0` requires a `referrer` | `REFERRER_MISSING` |
66
+ | `referrer` is a well-formed address | `REFERRER_MALFORMED` |
67
+ | `referrer` is not the zero address | `REFERRER_ZERO_ADDRESS` |
68
+
69
+ Every one of these **throws at provider mount**, not at swap time. A misconfigured widget refuses to render and tells you why, because the alternative — a widget that renders perfectly and quietly earns you nothing — is the failure mode you would not notice for a month.
70
+
71
+ `MAX_INTEGRATOR_FEE_BPS` is a client-side policy, not a contract limit (the on-chain `BipsLibrary` only rejects above 100%). It exists so no embedder can ship a widget that takes a third of a user's output and poisons the widget for everyone else.
72
+
73
+ ### Fee modes
74
+
75
+ `feeMode: "take-portion"` (default) puts the split inside the swap plan, as shown above.
76
+
77
+ `feeMode: "pay-portion"` moves it to the router level — the swap takes its full output to the router, `PAY_PORTION` forwards your fee, `SWEEP` returns the rest. Use it when the fee has to be taken across a plan the periphery cannot express alone. It costs an extra command and gives the router temporary custody, so it is not the default.
78
+
79
+ ### What is *not* fee-bearing
80
+
81
+ **Liquidity.** `TAKE_PORTION` splits an output currency; adding liquidity has no output, and removing it returns the user's own principal. Skimming that is a withdrawal charge, not a referral fee. The `integrator` config is still carried through the liquidity path so you can record attribution in analytics, but nothing is taken on-chain.
82
+
83
+ **Launches** are fee-bearing, because a launch buy *is* a swap. There is no separate launch call path: `buildLaunchBuyCall` checks the hook's gates and then calls `buildSwapCall`, so the `TAKE_PORTION` step is the same one. See [Launch](#launch).
84
+
85
+ ---
86
+
87
+ ## Install
88
+
89
+ ```bash
90
+ npm install @latchprotocol/widgets viem react react-dom
91
+ ```
92
+
93
+ `react`, `react-dom` and `viem` are peer dependencies, so your app's copies are used. `wagmi` is an optional peer — the widgets never import it, and you can bridge any wallet stack through the adapter interface.
94
+
95
+ Requires React 18 or 19, Node 20+ for the build.
96
+
97
+ ---
98
+
99
+ ## Quick start
100
+
101
+ ```tsx
102
+ import {
103
+ WidgetProvider,
104
+ SwapWidget,
105
+ createViemAdapter,
106
+ type ChainConfig,
107
+ } from "@latchprotocol/widgets";
108
+ import "@latchprotocol/widgets/styles.css"; // or let the widget inject it
109
+ import { createPublicClient, http } from "viem";
110
+
111
+ const chain: ChainConfig = {
112
+ chainId: 8453,
113
+ name: "Base",
114
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
115
+ contracts: {
116
+ vault: "0x...",
117
+ clPoolManager: "0x...",
118
+ binPoolManager: "0x...",
119
+ universalRouter: "0x...",
120
+ permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
121
+ },
122
+ blockExplorerUrl: "https://basescan.org",
123
+ };
124
+
125
+ const adapter = createViemAdapter({
126
+ chain,
127
+ publicClient: createPublicClient({ transport: http("https://your-own-rpc") }),
128
+ wallet: {
129
+ getAccount: async () => yourWallet.address ?? null,
130
+ sendTransaction: (tx) => yourWallet.send(tx),
131
+ },
132
+ tokens: yourTokenList,
133
+ pools: yourPoolList,
134
+ });
135
+
136
+ export function Swap() {
137
+ return (
138
+ <WidgetProvider
139
+ adapter={adapter}
140
+ integrator={{ referrer: "0xYourFeeRecipient", feeBps: 25 }}
141
+ theme="system"
142
+ >
143
+ <SwapWidget />
144
+ </WidgetProvider>
145
+ );
146
+ }
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Headless vs styled
152
+
153
+ Two layers, two entry points. Pick the one that matches how much of the UI you want to own.
154
+
155
+ ### Styled — `@latchprotocol/widgets`
156
+
157
+ Drop-in components: `<SwapWidget />`, `<LiquidityWidget />`, `<LaunchWidget />`. They render real form semantics, handle approvals and transaction states, and theme entirely through CSS custom properties.
158
+
159
+ ### Headless — `@latchprotocol/widgets/headless`
160
+
161
+ Hooks and pure functions. No markup, no stylesheet, no `react-dom/client` — so none of it lands in your bundle.
162
+
163
+ ```tsx
164
+ import {
165
+ WidgetProvider,
166
+ useSwapQuote,
167
+ useSwapExecute,
168
+ } from "@latchprotocol/widgets/headless";
169
+
170
+ function MySwapForm({ tokenIn, tokenOut, amountIn }) {
171
+ const { breakdown, status, rate } = useSwapQuote({ tokenIn, tokenOut, amountIn });
172
+ const { execute, step, statusMessage, needsApproval, approve } = useSwapExecute({
173
+ tokenIn, tokenOut, amountIn,
174
+ quote: quote.quote, breakdown,
175
+ });
176
+
177
+ // breakdown.integratorFee is already split out. Render it however you like.
178
+ }
179
+ ```
180
+
181
+ | | Headless | Styled |
182
+ | --- | --- | --- |
183
+ | Controllers (`useSwapQuote`, `useSwapExecute`, `useAddLiquidityQuote`, `useLaunchBuy`, …) | ✅ | ✅ |
184
+ | Quote math, formatting, call-path encoders | ✅ | ✅ |
185
+ | Adapters and config validation | ✅ | ✅ |
186
+ | Components and stylesheet | — | ✅ |
187
+
188
+ Going headless does **not** opt you out of fee validation: `WidgetProvider` validates the config, and the call-path builders refuse anything that did not come from it.
189
+
190
+ Available hooks: `useSwapQuote`, `useSwapExecute`, `useSlippageSetting`, `useAddLiquidityQuote`, `useAddLiquidityExecute`, `usePositions`, `useRemoveLiquidity`, `useLaunch`, `useLaunchList`, `useLaunchBuy`, `useTokenList`, `useTokenBalance`, `useAccount`, `usePools`, `usePoolState`.
191
+
192
+ ---
193
+
194
+ ## Non-React hosts
195
+
196
+ ### Custom elements
197
+
198
+ ```html
199
+ <script type="module">
200
+ import { defineLatchWidgets } from "@latchprotocol/widgets/embed";
201
+ defineLatchWidgets();
202
+ </script>
203
+
204
+ <latch-swap-widget
205
+ theme="dark"
206
+ integrator='{"referrer":"0xYourFeeRecipient","feeBps":25}'
207
+ ></latch-swap-widget>
208
+
209
+ <script type="module">
210
+ // A live adapter cannot come from an attribute (a viem transport is not JSON).
211
+ document.querySelector("latch-swap-widget").adapter = myAdapter;
212
+ </script>
213
+ ```
214
+
215
+ Three elements: `<latch-swap-widget>`, `<latch-liquidity-widget>`, `<latch-launch-widget>`. Each renders into a shadow root, so the widget's CSS cannot leak into your page and your CSS cannot break the widget — while `--latch-*` custom properties still inherit through the shadow boundary, which is exactly the theming behaviour you want.
216
+
217
+ ### The lock badge script (`latch-embed.js`)
218
+
219
+ For a project site with no bundler and no React, `dist/latch-embed.js` (IIFE, also `dist/latch-embed.mjs`) is one self-contained file: viem, the SDK's lock readers and the address book are inside it, and there is no React and no wallet code. It registers `<latch-lock-badge>` on load:
220
+
221
+ ```html
222
+ <script src="https://<latch site>/embed/latch-embed.js" defer></script>
223
+
224
+ <latch-lock-badge chain="4663" pool="0x…32-byte pool id…" site="https://<latch site>"></latch-lock-badge>
225
+ <latch-lock-badge chain="4663" token="0x…token…" theme="dark"></latch-lock-badge>
226
+ ```
227
+
228
+ | Attribute | |
229
+ |---|---|
230
+ | `chain` | chain id; must be in the SDK's address book, else the badge says Latch is not deployed there |
231
+ | `pool` / `token` | a pool id (add `pool-type="bin"` for a Bin pool) or a token address |
232
+ | `rpc` | read through this RPC instead of the chain's public endpoints |
233
+ | `site` | the Latch site origin; with it the badge links to the public lock page and shows the brand kit's mark (`<site>/brand/latch-mark-blue.png`, as shipped, 24 px), without it neither |
234
+ | `theme` | `light`, `dark` or `system` (default) |
235
+ | `refresh` | seconds between re-reads (default 60, minimum 15, `0` = never) |
236
+
237
+ It reads the same way as `LocksBadge` and says only what it read: "X% of active liquidity locked", "Locked permanently (LP locker)", "Y% of supply locked" (the share of supply not yet vested), "No locked liquidity". It never says "safe". The Latch site serves the file at `/embed/latch-embed.js` and has an iframe form at `/embed/lock-badge?net=<network>&pool=…|token=…`; each public lock page's **Embed badge** button prints both, filled in, with a live preview. The ESM entry `@latchprotocol/widgets/embed` exports `defineLatchLockBadge()` for bundled hosts.
238
+
239
+ The locker is not in this bundle: it signs transactions, and a framework-free script cannot bring a wallet. Use `LockerWidget` from React, or the Latch site's `/embed/locker?net=<network>` iframe, which carries its own wallet connect.
240
+
241
+ ### Iframe bridge
242
+
243
+ For hosts that will not run third-party script in their own realm — a reasonable position when the script asks users to sign transactions.
244
+
245
+ The widget runs in the iframe and owns only rendering. It holds no keys and opens no RPC connections. Every chain read and write is a request to your page, which answers it with your adapter and your wallet.
246
+
247
+ ```text
248
+ iframe (widget) parent (your page)
249
+ ──────────────── ──────────────────
250
+ ready ─────────────────────────────────▶
251
+ ◀───────────────────────────── init { chain, integrator, theme, isMock }
252
+ invoke { id, method, args } ───────────▶
253
+ ◀────────── result { id, ok: true, value } | { id, ok: false, error }
254
+ resize { height } ─────────────────────▶
255
+ event { name, payload } ──────────────▶
256
+ ```
257
+
258
+ Every message carries `{ channel: "latch-widget", version: 1, type }`; anything else is ignored. Both sides pin the counterpart origin and check `event.origin` — never `"*"`, and never anything inside `data`, which is attacker-controlled. `BigInt` survives structured clone, so amounts cross the boundary as exact integers; nothing is stringified.
259
+
260
+ Host side:
261
+
262
+ ```ts
263
+ import { serveWidgetIframe } from "@latchprotocol/widgets/embed";
264
+
265
+ const stop = serveWidgetIframe({
266
+ iframe: document.querySelector("iframe#latch")!,
267
+ widgetOrigin: "https://widgets.example", // exact origin, never "*"
268
+ adapter: myAdapter,
269
+ widget: "swap",
270
+ integrator: { referrer: "0xYourFeeRecipient", feeBps: 25 },
271
+ approveTransaction(request) {
272
+ // Last line of defence before you sign. The widget is still third-party code.
273
+ return (
274
+ request.to === MY_ROUTER &&
275
+ request.integratorFee?.referrer === MY_FEE_RECIPIENT
276
+ );
277
+ },
278
+ });
279
+ ```
280
+
281
+ Widget side (inside the iframe):
282
+
283
+ ```ts
284
+ import { createIframeBridgeAdapter } from "@latchprotocol/widgets/embed";
285
+
286
+ const { adapter, init } = await createIframeBridgeAdapter({
287
+ hostOrigin: "https://app.example",
288
+ });
289
+ ```
290
+
291
+ **You are the security boundary.** Before signing, check that `to` is a contract you expect, that `value` matches what the user was shown, and that `integratorFee.referrer` is your address. This contract makes the widget *inspectable*, not trusted.
292
+
293
+ ---
294
+
295
+ ## Theming
296
+
297
+ Every colour, radius, font and spacing value is a `--latch-*` custom property declared on `.latch-widget`. Override one from your page and the widget follows — nothing is hardcoded inside a rule.
298
+
299
+ ```css
300
+ .latch-widget {
301
+ --latch-accent: #ff4d6d;
302
+ --latch-accent-hover: #e04360;
303
+ --latch-radius: 4px;
304
+ --latch-font: "Your Brand Sans", system-ui, sans-serif;
305
+ --latch-bg: var(--your-card-background);
306
+ --latch-text: var(--your-body-colour);
307
+ }
308
+ ```
309
+
310
+ Light and dark ship by default. `theme="light" | "dark" | "system"` on the provider; `system` follows `prefers-color-scheme`.
311
+
312
+ The full token set: `--latch-bg`, `--latch-bg-elevated`, `--latch-bg-sunken`, `--latch-border`, `--latch-border-strong`, `--latch-text`, `--latch-text-muted`, `--latch-text-inverted`, `--latch-accent`, `--latch-accent-hover`, `--latch-accent-text`, `--latch-success`, `--latch-warning`, `--latch-warning-bg`, `--latch-danger`, `--latch-danger-bg`, `--latch-focus`, `--latch-radius`, `--latch-radius-sm`, `--latch-font`, `--latch-font-mono`, `--latch-font-size`, `--latch-font-size-sm`, `--latch-font-size-lg`, `--latch-gap`, `--latch-padding`, `--latch-control-height`, `--latch-shadow`.
313
+
314
+ **Getting the stylesheet in:** either `import "@latchprotocol/widgets/styles.css"`, or do nothing — the styled components inject it once on mount. The web component always injects it into its own shadow root. All three come from one source (`src/styles/css.ts`), so they cannot drift.
315
+
316
+ **Narrow layouts.** The widgets are single-column and fluid from 280px up, with a tighter padding and type scale below 380px. They are meant to live in a 360px sidebar and are tested at that width.
317
+
318
+ ---
319
+
320
+ ## Chain configuration
321
+
322
+ Nothing in this package knows which chain you are on. There are no bundled RPC URLs and no address book — a hardcoded address is a bug that ships to every host. Everything arrives through `ChainConfig`:
323
+
324
+ ```ts
325
+ interface ChainConfig {
326
+ chainId: number;
327
+ name: string;
328
+ nativeCurrency: { name: string; symbol: string; decimals: number };
329
+ contracts: {
330
+ vault: Address; // required
331
+ clPoolManager?: Address; // at least one pool manager required
332
+ binPoolManager?: Address;
333
+ universalRouter?: Address; // required to swap
334
+ clPositionManager?: Address; // required for CL liquidity
335
+ binPositionManager?: Address; // required for bin liquidity
336
+ permit2?: Address; // required to pull the input token
337
+ quoter?: Address; // CLQuoter; required to quote CL hops
338
+ binQuoter?: Address; // BinQuoter; required to quote liquidity-book hops
339
+ launchGuardHook?: Address; // required to read a launch schedule
340
+ launchGuardHookClock?: "timestamp" | "contract-block"; // else the SDK record, else CLOCK_MODE()
341
+ binLaunchGuardHook?: Address; // the Bin launch guard, timestamp-clocked
342
+ launchpadKitV2?: Address; // lets a launch be looked up by its token
343
+ };
344
+ transport?: Transport; // viem transport, if the adapter reads directly
345
+ blockExplorerUrl?: string;
346
+ defaultDeadlineSeconds?: number; // default 1200
347
+ }
348
+ ```
349
+
350
+ `assertValidChainConfig` runs at provider mount. A missing address fails at the operation that needs it, naming both — `contracts.universalRouter is not configured, but it is required to execute a swap` — rather than surfacing as a decode error.
351
+
352
+ ---
353
+
354
+ ## The adapter boundary
355
+
356
+ Every chain read and write goes through one interface, `ProtocolAdapter` ([`src/adapters/protocol.ts`](src/adapters/protocol.ts)). No component, hook or controller imports a viem client directly.
357
+
358
+ ### `createViemAdapter` — live
359
+
360
+ Produces real calldata and submits real transactions.
361
+
362
+ - **Encoding** is complete, unit-tested against the router and periphery sources, and — as of the fork suite below — **executed** against the live Sepolia deployment. See [Executed against a real deployment](#executed-against-a-real-deployment).
363
+ - **Token, balance and allowance reads** are ordinary ERC-20 and Permit2 calls.
364
+ - **Pool discovery** is not on-chain — the singleton has no pool enumeration. Pass the pools you support, or wire an indexer (`@latchprotocol/sdk/indexer` describes the schema).
365
+ - **Quoting** needs a deployed quoter or a host-supplied `overrides.quoteSwap`. Without one it throws `UnsupportedOperationError` rather than inventing a number. **A fabricated quote is worse than no quote.**
366
+
367
+ The same applies to `getPoolState`, `listPositions`, `quoteAddLiquidity` and `quoteRemoveLiquidity`: supply an override or get a named error, never silent zeros. "There is no liquidity" and "I could not find out" must not render identically.
368
+
369
+ ### Your own adapter
370
+
371
+ Implement `ProtocolAdapter` to route through your backend, your quoter, or wagmi. The widgets neither know nor care.
372
+
373
+ ---
374
+
375
+ ## Widgets
376
+
377
+ ### Swap
378
+
379
+ `<SwapWidget />` is the one swap surface Latch ships: the template, the hosted pad sites and the dapp all mount this component. Two large amount panels (Sell / Buy), each with a token selector dialog; a flip button between them; one call-to-action that always says the next step (connect, enter an amount, insufficient balance, approve, swap, confirming); and a details disclosure.
380
+
381
+ ```ts
382
+ interface SwapWidgetProps {
383
+ defaultTokenIn?: string; // pre-select the input token by address
384
+ defaultTokenOut?: string; // pre-select the output token by address
385
+ title?: string;
386
+ showSlippageControl?: boolean; // default true
387
+ onSwapConfirmed?: (hash: string) => void;
388
+ tokenLists?: readonly (TokenList | { url: string })[]; // Uniswap Token Lists, see below
389
+ onConnectRequest?: () => void; // makes "Connect wallet" a working button
390
+
391
+ // Host framing - all opt-in; without them the widget is the one above.
392
+ pair?: { token; quote } | { tokenIn; tokenOut }; // pin the pair (address or TokenInfo)
393
+ lockPair?: boolean; // with pair: selectors become read-only chips
394
+ side?: 'buy' | 'sell'; // controlled direction over the pair
395
+ onSideChange?: (side: 'buy' | 'sell') => void;
396
+ portions?: readonly number[]; // balance chips as fractions; default [0.25, 0.5, 0.75, 1]
397
+ onNeedsWallet?: () => void; // a portion was asked for with no balance to take it from
398
+ onQuote?: (quote: SwapQuoteView | null) => void;
399
+ slots?: { header?: ReactNode; aboveButton?: ReactNode; footer?: ReactNode };
400
+ variant?: 'default' | 'compact'; // compact: Buy / Sell pill, one "You pay" panel, lines always shown
401
+ creatorTax?: { buyBps: number; sellBps: number } | null; // its own line; the rate NOW (0 once expired)
402
+ }
403
+
404
+ interface SwapWidgetHandle { // via `ref`
405
+ setAmountIn(text: string): void;
406
+ setPortion(fraction: number): boolean;
407
+ flip(): void;
408
+ refreshQuote(): void;
409
+ }
410
+ ```
411
+
412
+ **Token lists.** `tokenLists` takes lists in the [Uniswap Token Lists](https://github.com/Uniswap/token-lists) standard, as documents already in hand or as `{ url }` entries the widget fetches through the SDK's `fetchTokenList` (schema-validated, size-capped, no caching). The adapter's own tokens — the tokens of the pools this site routes through, decimals read off their contracts — always come first and are never overridden by a list; then each list's tokens in list order, deduped by address, the first list to name an address winning and the others recorded as `listSource.alsoIn`. The picker groups entries by list, badges tokenised stocks from `extensions.stock` (issuer and ticker; `rebasing` is a warning), marks `test` and `launch` tags, resolves `ipfs://` logos through the SDK's allow-listed gateways, and names a list that failed to load instead of dropping it silently. A pasted address that is in no list is read off its contract and shown as **unlisted**. Latch's own list per chain is `latchTokenListUrl(chainId)` from the SDK; a runtime list of a kit's launches is `launchedTokenList` / `launchedTokenListFromScan`. The pure helpers (`combineTokens`, `tokensFromLists`, `groupBySource`, `resolveLogoUrl`) and the `useTokenLists` hook are exported from the headless entry.
413
+
414
+ Everything below the presentation is the headless layer (`useSwapQuote`, `useSwapExecute`); the component only renders it.
415
+
416
+ **Overlays are Radix primitives (MIT), portalled into the widget root.** The token selector is `@radix-ui/react-dialog`, the slippage settings `@radix-ui/react-popover`. Radix portals default to `document.body`, which is outside a shadow root and outside the `.latch-widget` theme scope, so `WidgetShell` provides its own portal container (`src/components/portal.tsx`) and every overlay renders inside it: dialogs and popovers inherit the `--latch-*` variables and stay styled inside the custom-element embed.
417
+
418
+ **The details list**, in order: price impact (with severity), max slippage, minimum received, pool fee, the integrator fee line, the route, and the quote's age.
419
+
420
+ - **Price impact comes from `getSlot0`, net of the known LP fee.** The live adapter prices the input at the pool's current `sqrtPriceX96` (CL) or `activeId` (Bin), deducts the pool's declared LP fee, and compares the quoted output against that; the shortfall is the impact (`priceImpactBps` in `src/core/math.ts`). When the pool's fee is **hook-set** and not readable in advance, the adapter reports **no spot at all** rather than an impact that silently includes the fee, so the row shows no number and the pool fee row reads `dynamic (hook-set)`. A price impact of 15% or more must be acknowledged before the button arms.
421
+ - **The integrator fee line** appears only when `integrator.feeBps > 0`: `<label> fee (0.25%)`, the amount in the output token, and the referrer it goes to — the same `TAKE_PORTION` step the transaction carries. `label` names the line; it is never sent on chain.
422
+ - **Minimum received** is the floor after slippage *and* the integrator fee, which is what the plan's final `TAKE` enforces.
423
+ - **No dollar figure anywhere.** Nothing in the adapter prices a token in USD, and a number that looks priced and is not would be an invented one.
424
+
425
+ `Max` on the native asset keeps a sliver back for gas; on an ERC-20 it uses the whole balance.
426
+
427
+ #### Framing the widget from a host page
428
+
429
+ A token page with its own Buy / Sell tabs, or a swap page that deep-links a pair, drives the widget through props rather than by remounting it with a `key` (a remount clears the typed amount and re-reads the token list). Every prop below is opt-in and the widget without them behaves exactly as before. The state behind them is pure (`swap/state.ts`, exported from both entries) and tested without a DOM.
430
+
431
+ **`pair` pins the pair.** Either shape: `{ token, quote }` is the anchored form (`token` is what is traded, `quote` what it is priced in); `{ tokenIn, tokenOut }` is the same pair in its buy orientation. Each entry is an address or a `TokenInfo`. An address is resolved against the widget's token list and, when the list lacks it, read off the token contract through the adapter (the path a pasted address takes); a `TokenInfo` is selected on the first frame. A change of `pair` re-pins without a remount, so the typed amount survives a host switching between a token's pools. Without `lockPair` the visitor can still pick other tokens; the pin returns when the prop changes.
432
+
433
+ **`lockPair`** (with `pair`) turns the two selectors into read-only chips: the token's logo and symbol, no chevron, no dialog, labelled "(fixed)". The pair cannot be changed from inside the widget. The flip button stays, because it flips the *side*, not the pair. The amount input is never remounted, so it keeps focus and value across quote refreshes and side changes.
434
+
435
+ **`side` orients the pair.** `buy` pays the quote and receives the token; `sell` the reverse. It is a controlled prop: when set, the widget's flip button does not move on its own but reports through `onSideChange`, and the host decides. When absent the widget owns its side and still reports every flip. A side change - from the prop, the flip button or `flip()` - **keeps the typed amount as the amount you pay** and re-quotes at once; it never clears the field (the widget is exact-input only, so the number in the field always means "what I pay"). The price-impact acknowledgement resets, because it was given for a different trade. `defaultTokenIn` / `defaultTokenOut` describe what the panels show on the initial side.
436
+
437
+ **`portions`** are the quick-amount chips under the "You pay" amount, as fractions in `(0, 1]` in the order given; `1` renders as "Max" (native keeps its gas sliver). Default `[0.25, 0.5, 0.75, 1]`; `[]` hides them; anything outside `(0, 1]` throws at render like every other bad config. The chips show only once a balance has been read: without a wallet there is no balance to take a portion of, and the widget does not invent one.
438
+
439
+ **`variant="compact"`** is a token page's trade box: a Buy / Sell pill in the header where the title was (the Buy side in the CVD-safe up colour, `--latch-buy`), one "You pay" panel with its chips, and every line always visible above the button, each on its own line: you receive, price impact, pool fee, the creator tax when `creatorTax` is given, minimum received (with the slippage in its label). Use it with a pinned `pair`; the pill changes the side exactly as `side` / `onSideChange` do and keeps the typed amount. The quote, the simulation before every send and the button are the default variant's. On the custom element: `variant="compact"`.
440
+
441
+ **`creatorTax`** is the launch guard's creator tax as the host read it (`getTax`), in bps for a buy and a sell, rendered as its own "Creator tax (buy|sell)" line in both variants — never folded into the pool fee or the price impact. Pass the rate NOW: 0 once `expiresAt` has passed, because the tax ends with no transaction. `null` reads "none"; absent, there is no line.
442
+
443
+ **The `ref` handle** lets a host draw its own chips or tabs and leave the trade to the widget. `setAmountIn("1.5")` writes the field; `setPortion(0.75)` writes a fraction of the input balance and returns `true`, or returns `false` and calls `onNeedsWallet` when there is no balance to take it from (nothing is written then; pass `onNeedsWallet={openConnectModal}` to turn a "Max" press without a wallet into a connect prompt, or leave it and give the host chip a title); `flip()` is the flip button; `refreshQuote()` re-reads now.
444
+
445
+ **`onQuote`** reports every quote the widget holds as a `SwapQuoteView` - `amountOut` (net of the integrator fee), `minReceived` (the floor the transaction enforces), `priceImpactBps` with its severity (`null` when the pool's fee is hook-set: an impact that silently included the fee would be an invented number), `lpFee.label` (a percentage or `dynamic (hook-set)`), the `route` and its `routeSymbols`, the display `rate`, `isRefreshing` and `updatedAt` - and `null` when there is none: empty amount, no route, or unmounted. It fires when the quote changes, including on each refresh, so a host prints the impact in its own header without running `useSwapQuote` a second time.
446
+
447
+ **`slots`** are the host's own chrome, rendered inside the widget's frame: `header` between the title row and the panels (a leg switcher, a note), `aboveButton` between the panels and the call to action, `footer` after the details and status line. The brand line ("Latch Protocol · chain") is not a slot and stays last.
448
+
449
+ **Provider presence.** `WidgetContext` and `useOptionalWidgetContext()` are exported, so a host that mounts the provider late (once its pool directory is read, say) tests for it directly - `useOptionalWidgetContext() === null` - and renders its waiting state, instead of catching the throw from `useWidgetContext` in an error boundary.
450
+
451
+ ```tsx
452
+ const widget = useRef<SwapWidgetHandle>(null);
453
+ const [side, setSide] = useState<'buy' | 'sell'>('buy');
454
+ const [quote, setQuote] = useState<SwapQuoteView | null>(null);
455
+
456
+ <SwapWidget
457
+ ref={widget}
458
+ pair={{ token: launch.token, quote: leg.quote }}
459
+ lockPair
460
+ side={side}
461
+ onSideChange={setSide}
462
+ onQuote={setQuote}
463
+ onNeedsWallet={openConnectModal}
464
+ slots={{ header: <LegSwitcher legs={legs} /> }}
465
+ />
466
+ <button onClick={() => widget.current?.setPortion(0.75)}>75%</button>
467
+ {quote ? <span>{formatPercentFromBps(quote.priceImpactBps)} impact · min {formatAmount(quote.minReceived, quote.tokenOut.decimals)}</span> : null}
468
+ ```
469
+
470
+ ### Liquidity
471
+
472
+ `<LiquidityWidget />` covers add and remove for **both** pool types behind one interface. The add/remove split is a tab; the CL/Bin split is not — the pool decides which range editor renders and the rest (amounts, quote, approvals, execution) is identical.
473
+
474
+ ```ts
475
+ interface LiquidityWidgetProps {
476
+ defaultPoolId?: string; // pre-select a pool by id
477
+ title?: string;
478
+ defaultMode?: "add" | "remove"; // which tab opens first
479
+ }
480
+ ```
481
+
482
+ - **Concentrated liquidity** — a tick pair, snapped to the pool's tick spacing, with widen/narrow controls (`adjustRange` moves both edges by whole spacings). The default range is ten spacings either side of the current tick.
483
+ - **Liquidity book** — a bin span around the active id (ten bins either side by default), with `idSlippage` of five, and a **uniform distribution** across it (`buildUniformBinDistribution`): bins below the active id take the Y side, bins above it the X side, the active bin both, and each side sums to exactly `1e18` with the rounding dust pushed into that side's last bin, as `BinPool.mint` requires.
484
+
485
+ Both emit the same `LiquidityRange` union, so everything above the range editor — amounts, quote, approvals, execution — is identical. The divergence is confined to [`src/callpath/liquidity.ts`](src/callpath/liquidity.ts). Positions for the remove tab come from `listPositions`: the live adapter reconstructs them from the position managers' transfer logs (confirmed against `ownerOf` / `balanceOf` and live pool state) and needs `positionsFromBlock` in its options, or it throws `UnsupportedOperationError` rather than scanning from genesis and reading as "no positions". No fee is taken on liquidity (see above).
486
+
487
+ ### Launch
488
+
489
+ **A Latch launch is not a token sale.** It is a concentrated-liquidity pool with `LaunchGuardHook` named in its `PoolKey`, a dynamic LP fee, and a fee that decays from `initialFeeBips` to `finalFeeBips` over a window starting at the launch start — `startTime`/`decaySeconds` (seconds of `block.timestamp`) on the current hook, `startBlock`/`decayBlocks` (contract blocks) on the block-numbered hook still deployed on Robinhood. Every decoded `LaunchGuard` carries its `durationClock`; the live adapter picks the matching ABI from `contracts.launchGuardHookClock`, the SDK address book, or the hook's own `CLOCK_MODE()`. There is no `buy`, no soft cap, no hard cap, no allocation and no claim. **Buying into a launch is an ordinary swap.**
490
+
491
+ So `<LaunchWidget />` is a launch-aware swap panel. It reads, from `LaunchGuardHook`, keyed by pool id:
492
+
493
+ | Read | From |
494
+ |---|---|
495
+ | the whole schedule — start, window (`startTime`/`decaySeconds` or `startBlock`/`decayBlocks`), `enabled`, the owner | `getLaunch(poolId)` |
496
+ | the fee being charged **right now** | `currentFee(poolId)` |
497
+ | the block those two were true at | `eth_blockNumber` |
498
+
499
+ and then renders the decay curve by evaluating `launchFeeAtBlock`, an exact mirror of the hook's `feeAt` — flooring included, so the projected fee rounds up toward the LPs the way the contract does. If the chain's `currentFee` and the local projection ever disagree for the same block, the widget shows the chain's number and says the curve may be wrong.
500
+
501
+ Buying delegates to the swap path. `useLaunchBuy` composes `useSwapQuote` and `useSwapExecute`, and `buildLaunchBuyCall` checks the hook's gates and then calls `buildSwapCall`. A launch buy that took a different code path from a swap would be a second call path to keep correct, and the second one always rots.
502
+
503
+ #### `maxBuyPerTx` is per transaction, not per wallet
504
+
505
+ The widget says this on screen, because it is the field most likely to be read as an allocation. From the hook's own source:
506
+
507
+ > Splitting a buy across N wallets or N transactions in the same block is NOT prevented and cannot be. `maxBuyPerTx` bounds one transaction, nothing more.
508
+
509
+ The reason is structural: `beforeSwap` receives the *locker* as `sender`, which under a shared router is the router for every buyer alike. No identity-based rule is implementable at that layer, so there is no per-wallet cap to display and the widget does not invent one. The cap also stops being enforced once the decay window ends.
510
+
511
+ #### Four states, and no fallback
512
+
513
+ | State | When | What it says |
514
+ |---|---|---|
515
+ | loading | a read is in flight | "Reading the launch schedule from chain" |
516
+ | **not-configured** | `contracts.launchGuardHook` is unset | names the chain, says no hook is deployed there, and tells you which config key to set. **No address is invented.** |
517
+ | empty | the hook answered and holds no record for the pool | explains that a pool id is only claimed by `configureLaunch` |
518
+ | error | the read failed | the error, plus "nothing is shown in place of the schedule" |
519
+
520
+ The adapters keep *not-configured* and *empty* apart deliberately: `listLaunches` throws `ChainConfigError` when no hook address is configured and returns `[]` when the hook is there and no pool uses it. An empty array in the first case would read as "the launchpad is here and nobody has launched", which is a different and untrue statement.
521
+
522
+ #### If you run your own sale contract
523
+
524
+ Latch has none, but you might. [`src/adapters/sale.ts`](src/adapters/sale.ts) defines `TokenSaleAdapter` — caps, allocations, per-wallet limits, price curves — as an optional `ProtocolAdapter.sale`. It defines the shape of an *answer*, not the shape of your contract: your `buildBuy` returns a `WidgetTransactionRequest` encoded against your own ABI. Nothing in this package reads it, and the styled `LaunchWidget` binds to `LaunchGuardHook` and to nothing else.
525
+
526
+ ### Locks — `LocksBadge`, `LocksPanel`
527
+
528
+ Read-only: a viem `PublicClient` and the lock contract from your address book, no provider and no wallet. `LocksBadge` is one line (the locked share of a pool's ACTIVE liquidity, or of a token's supply, a countdown to the next unlock, a link); `LocksPanel` is the full list. Pass the protocol's LP lockers (`lpLocker`, `binLpLocker`) and a launch's seed reads "Locked permanently (LP locker)". `linkTarget="_blank"` for a badge inside an iframe; `wording="embed"` for the embed badge's words.
529
+
530
+ ```tsx
531
+ <LocksBadge client={client} target={{ kind: "pool", poolId, positionLock: book.positionLock }} siteUrl="https://<latch site>" lockPathBase="/locks" />
532
+ ```
533
+
534
+ ### Token trust — `TokenTrustPanel`
535
+
536
+ One place with what the Latch contracts hold about a token, each row with the contract, the view and the blocks it was read at:
537
+
538
+ - **Latch launch**: whether `LaunchpadKitV2` launched it (`legsOf`); "Launch status unknown" when no kit is deployed, never "not a launch".
539
+ - **Token locks & vesting**: `LatchTokenLock` — share of supply locked (not yet vested) and held, next release.
540
+ - **Creator tax**: the Kit v2 launch guard's `getTax` / `currentTaxRates` per launch pool — buy and sell rates now, expiry, the frozen creator / protocol / integrator split. "No creator tax" only when the guard reads zero; "Not a Latch launch" only when the kit's `legsOf` is empty.
541
+ - **Per pool** (found through the launch legs, the LP lockers, `LatchPositionLock` and any `pools` you pass; the panel says the list is not every pool of the token): liquidity locks (locked share of active liquidity, permanent and time-locked), LP fee now (the guard's `currentFee` on a launch pool, the key's fee when static, "Dynamic (hook-set)" otherwise), protocol fee per direction, and what `LatchRegistry` records about the hook — listed or not, listing status, verification, risk class and its source. A listing is not an audit, and nothing here says "safe".
542
+
543
+ ```tsx
544
+ import { TokenTrustPanel } from "@latchprotocol/widgets";
545
+
546
+ <TokenTrustPanel
547
+ client={client}
548
+ token={token}
549
+ contracts={{
550
+ clPoolManager: book.clPoolManager,
551
+ binPoolManager: book.binPoolManager,
552
+ launchpadKitV2: book.launchpadV2.launchpadKitV2,
553
+ positionLock: book.positionLock,
554
+ tokenLock: book.tokenLock,
555
+ clLpLocker: book.launchpadV2.clLPLocker,
556
+ binLpLocker: book.launchpadV2.binLPLocker,
557
+ registry: book.registry,
558
+ }}
559
+ explorerAddressUrl={(a) => `${book.explorer}/address/${a}`}
560
+ />
561
+ ```
562
+
563
+ `contracts={null}` renders "Latch is not deployed on this chain". Headless: `useTokenTrust` / `fetchTokenTrust` here, `readTokenTrust` and the formatters (`describeLiquidity`, `describeCreatorTax`, `describeRegistry`, …) in `@latchprotocol/sdk/trust`; `tokenTrustView` turns a read into rows.
564
+
565
+ ### Locker — `LockerWidget`
566
+
567
+ A connected wallet locks a CL position NFT in `LatchPositionLock`, locks or vests tokens in `LatchTokenLock` (a time lock, or cliff + linear), and manages its own locks: collect fees, extend, withdraw after the date, claim what has vested, accept an offered lock.
568
+
569
+ ```tsx
570
+ import { LockerWidget } from "@latchprotocol/widgets";
571
+
572
+ <LockerWidget
573
+ client={client} // reads, simulations and receipts
574
+ contracts={{ positionLock: book.positionLock, tokenLock: book.tokenLock }}
575
+ wallet={wallet} // { getAccount, sendTransaction }, as createViemAdapter takes
576
+ onConnectRequest={openConnectModal}
577
+ />
578
+ ```
579
+
580
+ - **The fee is the contract's.** `feeWei` and any announced `pendingFee` are read live and shown before anything is signed, in native units; the lock call sends the higher of the two and the contract refunds the surplus in the same call. Embedding the widget elsewhere changes nothing about what is charged.
581
+ - **Exact approvals, only when missing**: the one position NFT (`approve(lock, tokenId)`), or exactly the amount (`approve(lock, amount)`); none for native.
582
+ - **Simulate, send, confirm**: every call is simulated with its exact bytes from the connected account before the wallet sees it, and every receipt is checked — a mined revert is reported as one. Errors go through the same decoder as every other widget (`describeError`), which knows the lock contracts' errors.
583
+ - Omit `wallet` under a `WidgetProvider` and the provider's adapter signs. `contracts` with both `null` renders "No Latch locker is deployed on this chain".
584
+ - Headless: `useLockerFees`, `useMyLocks`, `useLockerRunner`, `simulateSendConfirm`, and the pure `planPositionLock`, `planTokenLock`, `lockerFeeView`, `positionLockActions`, `tokenLockActions`.
585
+
586
+ ---
587
+
588
+ ## Accessibility
589
+
590
+ - Real `<form>` elements; submit works from the keyboard, and Enter in the amount field does what you expect.
591
+ - A `<label>` for every input, including visually-hidden labels on the token selectors.
592
+ - One `aria-live="polite"` region per widget. Quote refreshes and transaction transitions are **announced without moving focus**, so a background re-quote never yanks a keyboard user out of the amount field mid-typing.
593
+ - `role="alert"` for errors, `aria-invalid` + `aria-describedby` on invalid fields, `role="progressbar"` on the sale progress, `aria-busy` on in-flight buttons.
594
+ - Visible focus rings on every interactive element, using `--latch-focus`.
595
+ - `prefers-reduced-motion` respected.
596
+
597
+ ---
598
+
599
+ ## Development
600
+
601
+ ```bash
602
+ npm install
603
+ npm run typecheck # tsc --noEmit, strict + noUncheckedIndexedAccess
604
+ npm test # vitest, unit only
605
+ npm run test:fork # execute real calldata on an anvil fork of Sepolia
606
+ npm run test:all # both
607
+ npm run build # ESM + .d.ts + dist/styles.css
608
+ ```
609
+
610
+ Tests cover fee attribution (validation, rounding parity with `BipsLibrary.calculatePortion`, and that the fee is genuinely in the encoded calldata), quote math (including the property that a swap clearing the gross floor always leaves the user at or above the net floor), and call-path encoding for both pool types. The unit suites drive the widgets through a test-only adapter under `test/helpers/`; the package exports no mock adapter.
611
+
612
+ ### Executed against a real deployment
613
+
614
+ Those tests all assert an encoding by **decoding it back**, which proves the encoder agrees with itself and nothing more. A plan can round-trip through its own decoder and still be rejected on chain: wrong action order, wrong settle/take pairing, a delta left unsettled.
615
+
616
+ So there is a second suite that **sends the calldata**.
617
+
618
+ ```bash
619
+ npm run test:fork # anvil --fork-url <sepolia>, then execute
620
+ npm run test:all # unit + fork
621
+ ```
622
+
623
+ It boots an anvil forked from Sepolia and runs this package's ordinary public API — `createViemAdapter`, `buildSwapCall`, `buildCLMintCall`, `buildBinAddCall` — against the **live Latch deployment**: the real singleton, the real `UniversalRouter`, the real position managers, a real pool with real liquidity. Nothing is broadcast to Sepolia itself; the fork is local and disposable.
624
+
625
+ What it executes:
626
+
627
+ | | |
628
+ | --- | --- |
629
+ | Swap | exact-in fills at the quoted amount, to the wei |
630
+ | Fee composition | the pool charges `3997` pips, which decomposes to protocol `1000` + LP `3000` |
631
+ | Integrator fee | `TAKE_PORTION` pays the referrer `feeBps` of the **realised** output; the user still clears `minAmountOutNet`; no integrator pays nobody |
632
+ | `pay-portion` mode | router-level `PAY_PORTION` + `SWEEP`, with nothing left in the router |
633
+ | Two-minimum invariant | proved through a real fill at 0 bps slippage, not by arithmetic |
634
+ | Multi-hop | a two-hop `CL_SWAP_EXACT_IN` with a real `PathKey[]`, intermediate never leaving the vault |
635
+ | CL liquidity | mint and burn, amounts matching `SqrtPriceMath` to the wei in both rounding directions |
636
+ | Bin liquidity | add across five bins, swap through them, burn; shares land on `activeId + deltaId` |
637
+ | Failure modes | slippage, deadline and bin id-slippage revert **with the specific error**, and move no tokens |
638
+
639
+ Prerequisites: `anvil` on `PATH` and a reachable Sepolia RPC (`LATCH_FORK_RPC_URL` overrides the public default; `LATCH_FORK_BLOCK` pins a block). The suite deploys none of the protocol — it verifies the deployed wiring instead, including that Permit2 is the PancakeSwap fork the router's immutables name and not the canonical address.
640
+
641
+ The multi-hop case is the exception: Sepolia has one pool over two tokens, so that suite deploys a third `MockERC20` and a second pool on the fork. It needs `packages/core` built (`forge build`); everything else runs against deployed contracts only.
642
+
643
+ ---
644
+
645
+ ## Licence
646
+
647
+ MIT. See [LICENSE](LICENSE).
648
+
649
+ The protocol's core contracts are GPL-2.0-or-later. This package is independently authored from the compiled ABIs and contains no Solidity source, so building against it carries no obligation from the contracts' licence.